KYB for e-shops in three calls
A customer types an IČO into your B2B registration form. By the time they hit submit you know their legal name and address, whether their VAT number is valid in VIES, and whether you should invoice them on terms, demand prepayment, or refuse the account.
All four calls run on your server
Origin pinning — what is actually enforced
Origin pinning exists here, but it pins embed tokens, not API keys. Two separate mechanisms, and it matters which one you are relying on:
| Mechanism | Pinned to | What it protects |
|---|---|---|
sk_ API key | Nothing. No origin, domain or referrer column exists on the key. | Only secrecy. Keep it server-side. |
| CORS allow-list | A fixed list of Entyrix / NISMap hosts, identical for every key. | Your shop domain is not on it and cannot be added per key — a browser fetch from your origin receives no Access-Control-Allow-Origin and the browser discards the response. This applies to the unauthenticated /public/* endpoints too. |
/widget-token | One exact host, signed into an HMAC claim. | The only sanctioned browser-direct surface. Verified against Origin, then Referer; a request carrying neither is rejected. |
The practical consequence: none of autocomplete, company detail, credit score or monitoring is reachable from your customer's browser. Your PHP or Node backend calls Entyrix; the browser calls only your own origin. That is the architecture the recipe below assumes.
If you do want something in the browser
Mint a domain-pinned embed token server-side and hand only that to the page. It covers the credit, financials and graph widgets — not autocomplete, not company detail, not monitoring. The token is long-lived (30 days by default, 90 maximum) and the host match is exact: a token bound to shop.example is rejected for www.shop.example.
# Server-side: mint a token pinned to your shop's host.
curl -X POST -H "Authorization: Bearer $ENTYRIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain":"shop.example","widget_types":["credit"],"ttl_seconds":2592000}' \
"https://entyrix.com/api/v1/widget-token"
# Then embed the returned token in your page:
# <script src="https://entyrix.com/api/v1/widget.js"
# data-widget="credit" data-ico="31322832" data-country="SK"
# data-token="..."></script>
IČO autocomplete
Your form posts the typed string to your own endpoint; your backend proxies it here. Require at least two characters — shorter queries are noise.
curl -H "Authorization: Bearer $ENTYRIX_API_KEY" \ "https://entyrix.com/api/v1/companies/autocomplete?q=slovnaft&limit=3"
{
"data": [
{
"ico": "31322832",
"name": "SLOVNAFT, a.s.",
"legalFormName": "Akciová spoločnosť",
"municipality": "Bratislava",
"address": "Vlčie hrdlo 1",
"status": "active",
"country": "SK",
"subjectType": "legal_entity"
}
],
"meta": { "request_id": "req_b4b79236af1528c5", "duration_ms": 66,
"estimatedTotalHits": 65, "cached": false }
}
Show name, IČO and municipality in the dropdown — municipality is what disambiguates the near-identical group names. The status field tells you immediately whether the subject is active, in liquidation or terminated; greying out a terminated match saves a support ticket later.
Autofill the address, confirm the VAT number
Two calls: the company detail fills the invoice fields, the compliance summary tells you whether the VAT number is valid in VIES and whether anything disqualifying is on file.
curl -H "Authorization: Bearer $ENTYRIX_API_KEY" \ "https://entyrix.com/api/v1/companies/31322832" curl -H "Authorization: Bearer $ENTYRIX_API_KEY" \ "https://entyrix.com/api/v1/companies/31322832/compliance"
// GET /companies/31322832 — trimmed to the fields a checkout needs
{
"data": {
"ico": "31322832",
"name": "SLOVNAFT, a.s.",
"street": "Vlčie hrdlo",
"buildingNumber": "1",
"postalCode": "82412",
"municipality": "Bratislava",
"country": "SK",
"dic": "2020372640",
"icDph": "SK7120001713",
"vatId": "SK7120001713",
"status": "active",
"isActive": true,
"creditScore": 100,
"creditGrade": "A+"
},
"meta": { "request_id": "req_77ac8f6bcb216b0d", "duration_ms": 28, "cached": false }
}
// GET /companies/31322832/compliance — trimmed
{
"data": {
"overall": "STANDARD",
"recommendation": "Standard due diligence postačuje",
"flagsCount": { "critical": 0, "high": 0, "medium": 0, "low": 0 },
"signals": {
"sanctions": { "isSanctioned": false, "isDebarred": false },
"insolvency": { "inBankruptcy": false, "inLiquidation": false, "inRestructuring": false },
"tax": {
"reliability": "vysoko spoľahlivý",
"hasTaxDebt": false,
"isVatPayer": true,
"viesValid": true,
"viesValidatedAt": "2026-08-18T02:33:09.326Z"
}
}
},
"meta": { "request_id": "req_49206dde71d73f9b", "duration_ms": 19, "cached": false }
}
Read vatId, not a hand-rolled COALESCE of icDph and dic — vatId is the canonical VIES-shaped number and is already derived for you. dic stays the bare tax identifier; icDph stays the prefixed national form. viesValidatedAt tells you how fresh the VIES answer is.
The credit gate
One call decides payment terms. Branch on hardStop first and on score second — hardStop is a categorical disqualification, not a low number.
curl -H "Authorization: Bearer $ENTYRIX_API_KEY" \ "https://entyrix.com/api/v1/companies/36620319/credit-score"
// A healthy counterparty — hardStop is null, invoice on terms
{
"data": {
"ico": "31322832", "companyName": "SLOVNAFT, a.s.",
"score": 100, "grade": "A+", "baseline": 70,
"hardStop": null,
"warnings": []
}
}
// A company in bankruptcy — hardStop is set, block regardless of score
{
"data": {
"ico": "36620319", "companyName": "Potraviny Kačka, a.s. „v konkurze“",
"score": 0, "grade": "F", "baseline": 70,
"hardStop": "bankruptcy",
"factors": [
{ "name": "bankruptcy", "delta": -80, "note": "Konkurzné konanie" },
{ "name": "socialInsuranceDebt", "delta": -15, "note": "SocPoist dlh €35,283" },
{ "name": "negativeEquity", "delta": -25, "note": "Záporné vlastné imanie €17,451,133" }
],
"warnings": ["KRITICKÉ: firma v konkurze", "Záporné vlastné imanie (technický bankrot)"]
}
}
A sensible default mapping: hardStop set means refuse the account or prepay only; score under 40 means prepayment; anything above means invoice on your normal terms. Tune the threshold to your own bad-debt appetite — the factors array shows exactly what moved the number, so you can explain a refusal to the customer.
Keep watching after checkout
A credit check is a snapshot. Subscribe once at registration and you hear about it when the customer enters bankruptcy months later.
curl -X POST -H "Authorization: Bearer $ENTYRIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ico":"31322832","webhook_url":"https://shop.example/entyrix","tier":"free"}' \
"https://entyrix.com/api/v1/monitoring/subscriptions"
// 201 Created — webhookSecret is shown ONCE. Store it now.
{
"data": {
"id": 4,
"ico": "31322832",
"webhookUrl": "https://shop.example/entyrix",
"webhookSecret": "84d679a7…963de4d",
"webhookVersion": 2,
"digestFrequency": "immediate",
"tier": "free",
"email": null,
"createdAt": "2026-08-30T16:11:01.440Z"
},
"meta": {
"request_id": "req_b4f42e5e7a281251",
"notice": "webhookSecret sa zobrazuje IBA raz pri vytvorení. …"
}
}
Subscriptions are per company and deliver every event type the tier allows — there is no per-type filter, and no events[] or company_filter field in the request body. The free tier delivers 8 of the 33 types.
Coverage is genuinely uneven across markets — five types fire wherever we hold a registry row, several are Slovak/Czech only, and two have no production data at all. Do not promise your merchants a signal that does not fire in their country: the per-type source, severity, latency and country coverage table is in docs/api-reference.md § Monitoring, and the same list is machine-readable in the OpenAPI spec under MonitoringEventType.
What lands on your endpoint
POST /entyrix HTTP/1.1 Content-Type: application/json User-Agent: Entyrix-Webhook/1.0 X-Entyrix-Event-ID: 3f1c… ← idempotency key, dedupe on this X-Entyrix-Timestamp: 1788106367000 ← decimal ms since epoch X-Entyrix-Webhook-Version: 2 X-Entyrix-Signature: sha256=<hex> ← HMAC over "<timestamp>.<raw body>"
The signature covers the timestamp and the raw body joined by a dot, so you must read the body before any JSON middleware re-encodes it. Reject anything older than 5 minutes, compare in constant time, and deduplicate on X-Entyrix-Event-ID: a non-2xx reply is retried four times with exponential backoff, so the same event can legitimately arrive more than once.
<?php
/**
* Entyrix webhook receiver (signature v2).
* Read the RAW body — a parsed/re-encoded body will not match the signature.
*/
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_ENTYRIX_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_ENTYRIX_SIGNATURE'] ?? '';
$secret = getenv('ENTYRIX_WEBHOOK_SECRET'); // shown ONCE, at subscribe time
function entyrix_verify_webhook(
string $rawBody,
string $timestamp,
string $signature,
string $secret
): bool {
if (!preg_match('/^\d+$/', $timestamp)) {
return false;
}
$nowMs = (int) round(microtime(true) * 1000);
if (abs($nowMs - (int) $timestamp) > 300000) {
return false; // outside the 5-minute replay window
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature); // constant-time
}
if (!entyrix_verify_webhook($rawBody, $timestamp, $signature, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// Deduplicate on $event['eventId'] — a retry re-sends the same id.
// $event['eventType'] is the stable code; never branch on the Slovak summary.
if ($event['eventType'] === 'bankruptcy_change') {
// e.g. flip the customer to prepay-only
}
http_response_code(200); // anything outside 2xx is retriedimport { createHmac, timingSafeEqual } from "node:crypto";
/** Verify an Entyrix webhook (signature v2). */
export function verifyWebhook(rawBody, timestamp, signature, secret) {
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() - Number(timestamp)) > 300000) return false; // 5-min replay window
const expected =
"sha256=" + createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b); // constant-time
}
// Express — mount with a RAW body parser, not express.json():
// app.post("/entyrix", express.raw({ type: "application/json" }), handler)
export function handler(req, res) {
const rawBody = req.body.toString("utf8");
const ok = verifyWebhook(
rawBody,
req.get("X-Entyrix-Timestamp") ?? "",
req.get("X-Entyrix-Signature") ?? "",
process.env.ENTYRIX_WEBHOOK_SECRET
);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(rawBody);
// Deduplicate on event.eventId — a retry re-sends the same id.
// event.eventType is the stable code; never branch on the Slovak summary.
if (event.eventType === "bankruptcy_change") {
// e.g. flip the customer to prepay-only
}
res.sendStatus(200); // anything outside 2xx is retried
}Both files were executed against production before publication and returned identical results.
Errors and rate limits
Every error carries meta.request_id — quote it when you ask us about a specific call.
| Status | Meaning | What your code should do |
|---|---|---|
| 400 | INVALID_COUNTRY, INVALID_ICO — malformed input | Fix the request; do not retry. |
| 401 | UNAUTHORIZED — missing or unknown key | Alert an operator. Never retry in a loop. |
| 403 | FORBIDDEN — key disabled or expired | Alert an operator. |
| 404 | Subject not found or FO-gated. Codes differ per route (NOT_FOUND, COMPANY_NOT_FOUND, FO_GATED_NO_DPA). | Branch on the HTTP status, not on error.code. Fall back to manual entry. |
| 429 | RATE_LIMITED — see the two limits below | Sleep for Retry-After seconds, then retry once. Do not hammer. |
| 5xx | Transient upstream failure. | Retry once with backoff, then fail open to manual review. |
Two limits, not one
A shared ceiling of roughly 600 requests per minute is bucketed by your bearer token (falling back to IP for unauthenticated calls), and a separate per-key allowance defaults to 120 per minute in a fixed 60-second window. Both surface X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; only the shared limiter adds Retry-After, which is why the snippets default to a one-second sleep when the header is absent.
A registration form does not come close to either limit. A bulk re-scoring of your existing customer base does — page it, or use the bulk endpoint that takes 100 identifiers at once.
Copy-paste client
All three steps, with the retry and error handling already wired. Set ENTYRIX_API_KEY in your server environment — not in a theme file, not in a JS bundle.
<?php
/**
* Entyrix KYB — server-side B2B registration check.
* Drop into modules/yourmodule/src/EntyrixKyb.php (PrestaShop) or any PSR-4 tree.
* Runs on your server ONLY. The sk_ key must never reach the browser.
*/
final class EntyrixKyb
{
private const BASE = 'https://entyrix.com/api/v1';
public function __construct(private string $apiKey) {}
/** Low-level call. Retries once on 429/5xx, honouring Retry-After. */
private function call(string $path, array $query = []): array
{
$url = self::BASE . $path . ($query ? '?' . http_build_query($query) : '');
for ($attempt = 0; $attempt < 2; $attempt++) {
$retryAfter = 0;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Accept: application/json',
],
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$retryAfter) {
if (stripos($header, 'retry-after:') === 0) {
$retryAfter = (int) trim(substr($header, 12));
}
return strlen($header);
},
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false) {
return ['status' => 0, 'body' => null, 'error' => $err];
}
if (($status === 429 || $status >= 500) && $attempt === 0) {
sleep(max(1, min($retryAfter, 5)));
continue;
}
return ['status' => $status, 'body' => json_decode($raw, true), 'error' => null];
}
return ['status' => 0, 'body' => null, 'error' => 'unreachable'];
}
/** Step 1 — IČO autocomplete for the registration form. */
public function suggest(string $q, int $limit = 8): array
{
if (mb_strlen(trim($q)) < 2) {
return [];
}
$r = $this->call('/companies/autocomplete', ['q' => $q, 'limit' => $limit]);
return $r['status'] === 200 ? ($r['body']['data'] ?? []) : [];
}
/** Step 2 — autofill + VIES. null when absent, terminated-unknown or FO-gated. */
public function profile(string $ico): ?array
{
$detail = $this->call('/companies/' . rawurlencode($ico));
// 404 also covers sole traders: error.code = FO_GATED_NO_DPA.
if ($detail['status'] !== 200) {
return null;
}
$c = $detail['body']['data'];
$comp = $this->call('/companies/' . rawurlencode($ico) . '/compliance');
$vies = $comp['status'] === 200
? ($comp['body']['data']['signals']['tax']['viesValid'] ?? null)
: null;
return [
'name' => $c['name'],
'street' => $c['street'],
'city' => $c['municipality'],
'postcode' => $c['postalCode'],
'country' => $c['country'],
'vat_id' => $c['vatId'],
'dic' => $c['dic'],
'status' => $c['status'],
'vies_valid' => $vies,
];
}
/** Step 3 — credit gate. */
public function creditGate(string $ico): array
{
$r = $this->call('/companies/' . rawurlencode($ico) . '/credit-score');
if ($r['status'] !== 200) {
// Fail OPEN to manual review — never silently approve on an outage.
return ['decision' => 'review', 'reason' => 'score_unavailable'];
}
$d = $r['body']['data'];
if (!empty($d['hardStop'])) {
return ['decision' => 'block', 'reason' => $d['hardStop'], 'grade' => $d['grade']];
}
if ((int) $d['score'] < 40) {
return ['decision' => 'prepay', 'reason' => 'low_score', 'grade' => $d['grade']];
}
return ['decision' => 'invoice', 'grade' => $d['grade'], 'score' => $d['score']];
}
}/**
* Entyrix KYB — server-side B2B registration check (Node 18+).
* Runs on your server ONLY. The sk_ key must never reach the browser.
*/
const BASE = "https://entyrix.com/api/v1";
export class EntyrixKyb {
constructor(apiKey) {
this.apiKey = apiKey;
}
/** Low-level call. Retries once on 429/5xx, honouring Retry-After. */
async call(path, query = {}) {
const url = new URL(BASE + path);
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v));
for (let attempt = 0; attempt < 2; attempt++) {
let res;
try {
res = await fetch(url, {
headers: { Authorization: "Bearer " + this.apiKey, Accept: "application/json" },
signal: AbortSignal.timeout(8000),
});
} catch (err) {
return { status: 0, body: null, error: String(err) };
}
if ((res.status === 429 || res.status >= 500) && attempt === 0) {
const wait = Number(res.headers.get("retry-after") || 1);
await new Promise((r) => setTimeout(r, Math.min(Math.max(wait, 1), 5) * 1000));
continue;
}
return { status: res.status, body: await res.json().catch(() => null), error: null };
}
return { status: 0, body: null, error: "unreachable" };
}
/** Step 1 — IČO autocomplete for the registration form. */
async suggest(q, limit = 8) {
if (q.trim().length < 2) return [];
const r = await this.call("/companies/autocomplete", { q, limit });
return r.status === 200 ? (r.body?.data ?? []) : [];
}
/** Step 2 — autofill + VIES. null when absent or FO-gated. */
async profile(ico) {
const detail = await this.call("/companies/" + encodeURIComponent(ico));
// 404 also covers sole traders: error.code = FO_GATED_NO_DPA.
if (detail.status !== 200) return null;
const c = detail.body.data;
const comp = await this.call("/companies/" + encodeURIComponent(ico) + "/compliance");
const viesValid =
comp.status === 200 ? (comp.body?.data?.signals?.tax?.viesValid ?? null) : null;
return {
name: c.name,
street: c.street,
city: c.municipality,
postcode: c.postalCode,
country: c.country,
vatId: c.vatId,
dic: c.dic,
status: c.status,
viesValid,
};
}
/** Step 3 — credit gate. */
async creditGate(ico) {
const r = await this.call("/companies/" + encodeURIComponent(ico) + "/credit-score");
// Fail OPEN to manual review — never silently approve on an outage.
if (r.status !== 200) return { decision: "review", reason: "score_unavailable" };
const d = r.body.data;
if (d.hardStop) return { decision: "block", reason: d.hardStop, grade: d.grade };
if (d.score < 40) return { decision: "prepay", reason: "low_score", grade: d.grade };
return { decision: "invoice", grade: d.grade, score: d.score };
}
}Both files were executed against production before publication and returned identical results.
Wiring it into PrestaShop
Call profile() from an AJAX controller behind your own front controller token and write the result into the address fields; call creditGate() from actionValidateCustomerAddressForm or your registration hook. Keep the key in $_SERVER / your parameters file — a key in config/settings.inc.php committed to the shop repository is the most common way these leak.
Need a key?
Write to [email protected] and say which markets you sell into — coverage differs per country and we will tell you honestly what fires where.