Components
ElvixLegalEntityPicker

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.

app/bookings/invoice-company.tsx
"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">
<ElvixLegalEntityPicker
card={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. */}
<button
type="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

PropTypeRequiredDefaultDescription
onPick(entity: LegalEntityRecord, pick: { granted: boolean }) => voidrequired—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.
selectedIdstring | nulloptional—The picked entity, controlled by you. Pass the current id to reopen with it picked ("change company").
types("individual" | "sole_prop" | "company")[]optionalall threeWhich 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.
cardbooleanoptionaltruefalse drops the elvix card so the picker sits in your own pane.
sizing"fixed" | "flexible"optionalcard ? "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, maxHeightnumber | stringoptional—Fixed mode's height; maxHeight caps either mode (and then scrolls inside).
onResult(result) => voidoptional—{ 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.

FieldNotes
idWhat you send to your backend. Nothing else from the browser.
withheldtrue 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.
vatIdValidationelvix's own check, never the browser's: live (the register confirmed the number), format, none.
vatIdNameMatchlive proves the NUMBER exists, not the company: match, mismatch (another company's VAT ID), unknown (the register shares no name, e.g. Germany).
vatIdNormalised as printed on an invoice: uppercase, no separators, with its country prefix.
givenName, familyNameA person's name in its two parts (individuals and sole proprietors).
peppolId, codiceDestinatario, pecAddress, leitwegIdWhere 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.

server/invoice.ts
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 enough
applicationId: process.env.ELVIX_APP_ID!, // the internal id, not the clientId
userId: session.sub, // the signed-in user's elvix id
entityId,
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.
}
bash
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.

app/globals.css
/* 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.

Related