ElvixLegalEntityPicker
Pick which company an invoice or a payout is for, or add one inline. Your backend then reads it from elvix by id, with elvix's own VAT and name checks.
Nothing picked yet
A list of the user's legal entities: one tap picks, and the picked row stays tinted. 'Add a company' runs the add wizard inline (VAT ID first, prefilled from the register) and comes back with the new one picked. There is no confirm button and no done pane: your own Continue ends the step.
"use client";import {ElvixLegalEntityPicker,type LegalEntityRecord,useElvixLegalEntityGrant,} from "@elvix.is/sdk/react";import { useState } from "react";export function InvoiceCompanyStep({ onContinue }: { onContinue: (entityId: string) => void }) {const [picked, setPicked] = useState<{ entity: LegalEntityRecord; granted: boolean } | null>(null);const grant = useElvixLegalEntityGrant();return (<div className="invoice-pane"><ElvixLegalEntityPickercard={false}types={["sole_prop", "company"]}selectedId={picked?.entity.id ?? null}onPick={(entity, { granted }) => setPicked({ entity, granted })}/>{/* Your own Continue. A pre-selected default is not granted yet: grant it here. */}<buttontype="button"disabled={!picked}onClick={async () => {if (!picked) return;if (!picked.granted && !(await grant(picked.entity.id)).ok) return;onContinue(picked.entity.id); // send ONLY the id to your backend}}>Continue</button></div>);}
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
onPick | (entity: LegalEntityRecord, pick: { granted: boolean }) => void | required | — | A tap or a save in "Add a company" grants first, then calls onPick(entity, { granted: true }). The pre-selection (the default, else the only entity) calls onPick(entity, { granted: false }) with no request: grant it from your Continue with useElvixLegalEntityGrant(), so nothing is shared without the user's step. |
selectedId | string | null | optional | — | The picked entity, controlled by you. Pass the current id to reopen with it picked ("change company"). |
types | ("individual" | "sole_prop" | "company")[] | optional | all three | Which entities are listed and which "Add" offers. One type skips the type question. |
purpose | "invoicing" | "payouts" | optional | "invoicing" | What the pick lets your backend read, and how "Add" asks. Invoicing: the VAT ID (or tax number), the legal name prefilled from the register, the address, an optional invoice email; nothing else, except an e-invoice address for Italy (SDI code or PEC) and Belgium (Peppol ID), the two places a B2B invoice cannot be sent without one. Payouts: the full form, with birth data and registration skippable, read as a prefill for your payment provider; the picker says so above the list, and elvix mails the person each new payouts grant with a link to stop it. The user sees which on elvix.is and can stop each. |
card | boolean | optional | true | false drops the elvix card so the picker sits in your own pane. |
sizing | "fixed" | "flexible" | optional | card ? "fixed" : "flexible" | Fixed: the list and every add step share one height (min(520px, 70dvh), or height) and scroll inside it. Flexible: as tall as the step showing, no inner scroll, your page scrolls. |
height, minHeight, maxHeight | number | string | optional | — | Fixed mode's height; maxHeight caps either mode (and then scrolls inside). |
onResult | (result) => void | optional | — | { ok: true, entityId } after a grant, { ok: false, error } when it or a save fails (grant_failed, missing_client_id, save_failed). The failure is also shown in the frame with a retry. |
What onPick hands you
The entity as your page may see it. Render the step from it; never post it to your backend as the invoice's data, because a browser can change anything it holds.
| Field | Notes |
|---|---|
id | What you send to your backend. Nothing else from the browser. |
withheld | true in your app: birth date, place of birth, nationality, phone (and an individual's tax number, home address and the register's copy of either) come back null here and are write-only. Your backend reads them with a payouts grant. |
vatIdValidation | elvix's own check, never the browser's: live (the register confirmed the number), format, none. |
vatIdNameMatch | live proves the NUMBER exists, not the company: match, mismatch (another company's VAT ID), unknown (the register shares no name, e.g. Germany). |
vatId | Normalised as printed on an invoice: uppercase, no separators, with its country prefix. |
givenName, familyName | A person's name in its two parts (individuals and sole proprietors). |
peppolId, codiceDestinatario, pecAddress, leitwegId | Where e-invoices go, when the business gave one: Peppol (<scheme>:<id>), Italy's SDI code or PEC, Germany's Leitweg-ID (public bodies). The add flow asks only Italian (SDI code or PEC) and Belgian (Peppol ID) businesses; elsewhere an e-invoice goes by email or is routed by the VAT ID itself (DE, PL, FR, RO, HR). Your backend also gets vatIdScheme, taxIdScheme and registrationNumberScheme: the Peppol EAS codes to label each number with (9930 = German VAT ID). |
address* | Line, postal code, city, region, ISO country: required for businesses (VAT Directive art. 226(5)). |
Your backend reads it from elvix
Every grant is per app and per purpose, until the user stops sharing it at elvix.is (their account's app page or Tax & business). A read-only API key is enough; the grant is the gate. Also an MCP tool (get_applications_id_users_uid_legal_entities_entityId) and in the API reference.
import { getElvixLegalEntity } from "@elvix.is/sdk/server";// The browser sent only the id. Read the rest from elvix.const result = await getElvixLegalEntity({apiKey: process.env.ELVIX_API_KEY!, // a READ-ONLY key is enoughapplicationId: process.env.ELVIX_APP_ID!, // the internal id, not the clientIduserId: session.sub, // the signed-in user's elvix identityId,purpose: "invoicing", // or "payouts", whichever the user granted});if (!result.ok) {// "not_found": not granted for this purpose, revoked, deleted, another user's,// or the user left your app. "invalid_id": not an id (no request was made).throw new Error(`company not available: ${result.error}`);}const company = result.entity;if (company.vatIdNameMatch === "mismatch") {// A real VAT ID under another company's name: do not issue a reverse-charge invoice.}
curl "https://elvix.is/api/applications/$APP_ID/users/$USER_ID/legal-entities/$ENTITY_ID?purpose=invoicing" \-H "Authorization: Bearer $ELVIX_API_KEY"# 200 { "success": true, "data": { "entity": { "purpose": "invoicing", "grantedAt", "updatedAt",# "id", "type", "legalName", "givenName", "familyName", "taxCountry", "taxId", "vatId",# "vatIdValidation", "vatIdValidatedAt", "vatIdValidatedName", "vatIdValidatedAddress",# "vatIdRequestId", "vatIdNameMatch", "vatIdScheme", "taxIdScheme", "registrationNumberScheme",# "registrationNumber", "registrationBody", "contactEmail",# "peppolId", "codiceDestinatario", "pecAddress", "leitwegId",# "addressLine1", "addressLine2", "addressPostalCode", "addressCity",# "addressRegionName", "addressRegionCode", "addressCountry", "addressCountryName", "addressFormatted" } } }# ?purpose=payouts adds what the person saved of tradingName, dateOfBirth, placeOfBirth,# nationality, contactPhone, registeredSince (and an individual's taxId): a prefill# for your payment provider's own form (elvix collects no KYC)# 404 not_found: one answer for every refusal, so ids cannot be probed
Set your own EU VAT ID on the app (Console → Legal, or PATCH /api/applications/{id} with vatRequesterId; saved only once VIES confirms it: vat_requester_invalid, or vat_requester_unconfirmed to retry while VIES is down) and elvix asks VIES in your name on reads, at most daily per grant: vatIdRequestId is then YOUR consultation number, the proof you checked the buyer's VAT ID, and vatIdNameMatch comes from VIES where the member state compares names. A consultation counts only for the VAT ID, name, address and requester it checked: after any change it is asked again, and until VIES answers the read carries none. Snapshot what you invoice from the read you invoice with.
Webhooks, only to apps holding a grant: user.legal_entity_updated, user.legal_entity_deleted, user.legal_entity_revoked, each with { userId, entityId, purpose, updatedAt }. Snapshot the entity onto what you issue; an update later does not change an invoice already issued.
Theming
Every colour, fill, radius and the focus ring is a CSS variable, set on any ancestor. Fields have a tint fill and no outline unless you set --elvix-field-border. The SDK sets no font, so yours applies.
/* Any ancestor of the picker. Map elvix's variables onto your own tokens,and light / dark follows your theme by itself. */.invoice-pane {--elvix-field: var(--fill); /* text field fill */--elvix-field-border: transparent; /* no outline at rest (the default) */--elvix-field-focus: var(--fill-focus); /* focused: a deeper tint, never an outline */--elvix-field-radius: 12px;--elvix-row: var(--fill); /* a list row */--elvix-row-hover: var(--fill-strong);--elvix-picked: color-mix(in srgb, var(--accent) 10%, transparent);--elvix-picked-text: var(--accent); /* the picked row's name */--elvix-radius: 12px; /* rows and cards */--elvix-fg-1: var(--ink); /* text */--elvix-fg-3: var(--muted); /* secondary text */--elvix-focus: var(--accent); /* focus ring */--elvix-danger: var(--error); /* a field's error outline */--elvix-icon: var(--accent); /* the icons in their round bubbles */--elvix-icon-bg: var(--fill-strong); /* the bubbles behind them */--elvix-key-hints: inline-flex; /* show the "Enter" chip (off by default) */font-family: inherit; /* the SDK sets no font */}
The picker needs <ElvixProvider clientId>: the grant is per app. Without one it says so in the frame and reports missing_client_id, rather than letting a pick your backend cannot read look done.