# Embed and card verification

> The enrolment form on your site, the association's public profile and card verification online and offline.

URL: https://developers.vimine.network/en/guides/embed-and-verify/

## The enrolment form on your site

Every association that enabled public enrolment has a page `https://app.vimine.network/enroll/<slug>`
that can be placed in an iframe on the association's site:

```html
<iframe src="https://app.vimine.network/enroll/your-slug" style="width:100%;min-height:900px;border:0"></iframe>
```

The association must list the site's domain among its **embed domains** in its settings. The sign-in and
consent page of *Sign in with Vimine*, on the other hand, **cannot** be placed in an iframe: it is always a
full-page on vimine.network.

`?locale=en` opens the form in English (the `<vimine-enroll>` element passes it by itself, from the page's language).

### The form's look

The form can take your site's colours. A page in an iframe cannot see its host's CSS, so the style travels in the
address:

| Parameter | Values | |
|---|---|---|
| `scheme` | `light`, `dark` | the page's scheme; without it, the background's or the visitor's |
| `accent` | a hex colour (`1e3a5f`) | buttons, links, focused fields and the association's name |
| `background` | a hex colour, or `transparent` | the ground behind the cards; `transparent` lets your page show through |
| `font` | `inter`, `roboto`, `open-sans`, `montserrat`, `nunito`, `lora`, `merriweather`, `roboto-slab`, `system` | the face of the whole page, fields included, among those Vimine serves itself (`system`: the device's own) |

```html
<iframe src="https://app.vimine.network/enroll/your-slug?scheme=light&accent=1e3a5f&background=transparent"
        style="width:100%;min-height:900px;border:0"></iframe>
```

Other values are ignored. With `<vimine-enroll>` there is no address to write: set `--vimine-scheme`, `--vimine-accent`,
`--vimine-background` and `--vimine-typeface` in your CSS ([elements](https://developers.vimine.network/en/sdk/elements/)). With `transparent`, pick the scheme that reads on
your page: the form's cards have their own fill, the text outside them sits on yours.

### Someone who already has a Vimine account

One identity serves every association: someone who already has an account need not fill the form again. Opened
directly, the page `…/enroll/<slug>` recognises the session and shows **one screen** — the membership type, the statuto
and the privacy notice of *this* association, and only what this association asks on top of that (the address, where it
declared that it collects one). The statuto and the notice are accepted every time: nothing passes from one
association to another.

Inside an iframe the session cannot be read: a browser keeps a framed page apart from the same site opened directly.
So the framed form offers *Already have a Vimine account? Continue on Vimine*, which opens the page in a new tab. If you
write the iframe by hand and use `sandbox`, include `allow-popups` and `allow-popups-to-escape-sandbox`, or that tab
stays inside the sandbox; `<vimine-enroll>` already does.

## The public profile

`GET /v1/aps/{slug}` returns the association's public profile — branding, membership types with their fees,
in-force statuto — if you want to build an experience of your own:

```js
import { publicApi } from '@vimine/sdk';

const { aps, membership_types, statuto } = await publicApi().association('your-slug');
```

No credentials. From a page where the member did *Sign in with Vimine* you can then collect the request:

```js
const { aps, membership_types, statuto, privacy_notice } = await publicApi().association('your-slug');

await vimine.membership.request({
    membership_type,
    accept_statuto: { version: statuto.version },
    // the association's privacy notice, which your page showed
    privacy_notice: privacy_notice && { version: privacy_notice.version, sha256: privacy_notice.sha256, locale: privacy_notice.locale },
    // the address, where the association collects one (aps.member_address: 'optional' | 'required')
    address: { line: 'Via Roma 1', postal_code: '20100', city: 'Milano', province: 'MI' }
});
```

What an association asks of an applicant does not depend on the door the request comes through:

- **the privacy notice.** While the association has one in force that the person has not seen yet, `privacy_notice` is
  required: without it, `422 validation_failed` on the `privacy_notice` field; with a version that is no longer in
  force, `422 privacy_notice_outdated`. `vimine.legal.acceptances()` says what is still to confirm; the text is
  `GET /v1/aps/{slug}/privacy-notice`. The acknowledgement is kept as a proof row tied to this membership.
- **the address.** `aps.member_address` says whether the association collects it: with `required`, a request without
  `address` answers `422 validation_failed` on the `address` field; with `none` it is ignored.
- **who the person is.** When the profile is not identified yet — neither a fiscal code, nor date, place and country of
  birth — the request answers `422 identification_required`: complete the profile with `vimine.me.update(...)` and
  repeat (see [Members without a fiscal code](https://developers.vimine.network/en/guides/members-without-cf/#identification_required)).

:::caution[Changed on 2 October 2026]
Up to version `2026-09-27` of the API this request asked for neither the notice nor the address. An integration that
calls it without `privacy_notice` for an association with a notice in force now receives `422`.
:::

## Card verification

The digital card is a **signed token** (`vmt_…`) the member shows as a QR code. It can be verified in two ways.

### Online

No credentials, no personal data in the response:

```
GET https://api.vimine.network/v1/verify/vmt_…
→ { "status": "active", "valid_until": "2027-03-31T22:59:59.000Z" }
   or
→ { "status": "inactive", "reason": "expired" }
   or
→ { "status": "inactive", "reason": "not_yet_valid", "valid_from": "2026-09-19T19:00:00.000Z" }
```

`not_yet_valid`: the card is genuine and the membership is active, but the organisation set an interval between
admission and the card being usable. `valid_from` says when it starts to count; until then, at the door, it is a card
that does not count. The same instant is in the token (`nbf`), so an offline scanner reaches the same answer alone.

```js
import { publicApi } from '@vimine/sdk';

const { status } = await publicApi().verifyCard(tokenReadFromQr);
```

### Offline

The token is signed with **Ed25519**; the public keys are at
`https://api.vimine.network/.well-known/vimine-card-keys.json` (also `GET /v1/card-keys`, or
`publicApi().cardKeys()`). A scanner verifies **three things** with the cached key: the signature, the validity window (`nbf`/`exp`, at most 90
days, then the token rotates) and that the status in the payload is active — **`s === "active"`**. Then it re-syncs
online when the network is back. An unknown `kid`
means the key rotated: reload the keys.

The token rotates on every status change, on every renewal and every 90 days anyway: an old screenshot does
not open anything forever.

A card exists only for an **active** membership: while the request is pending (admission or fee), and when the
membership is suspended or revoked, the API issues no token — `card` is `null` in `GET /v1/me/membership`, and
`GET /v1/me/membership/card`, the image, the e-mail and the Google Wallet link answer `409 not_active`. Checking `s`
stays mandatory: it rejects tokens issued before this rule and any token that does not declare an active status.
Honour `nbf` too: an organisation may decide that a card becomes usable some hours after admission, and that instant
travels there.

:::caution
Offline verification says the token was **authentic and inside its window** when issued, not that the card is
active this very instant: a suspension that happened afterwards only shows online. Re-sync as soon as you can.
:::

## Fiscal code

The fiscal code is **optional**: someone who has none joins with their date, place and country of birth (see
[Members without a fiscal code](https://developers.vimine.network/en/guides/members-without-cf/)). When there is one, `POST /v1/validate/fiscal-code`
(or `publicApi().validateFiscalCode(...)`) checks the structure and the check character of an Italian codice fiscale
and, if you also pass the personal data, their consistency with the code — and says what the code itself holds:

```js
const r = await publicApi().validateFiscalCode({ code: 'RSSMRA80A01H501U', first_name: 'Mario', last_name: 'Rossi', date: '1980-01-01', place: 'Roma', country: 'IT' });
// { valid: true, error: null, mismatches: [], hints: [], birth_date: '1980-01-01', birth_country: 'IT' }
//   or
// { valid: false, error: 'mismatch', mismatches: ['date'], hints: [], birth_date: '1980-01-01', birth_country: 'IT' }
```

`error` is `invalid` (structure or check character), `mismatch` (a well-formed code that disagrees with the data
sent) or `null`; `hints` holds `names_swapped` when the code matches once surname and given name are exchanged. For a
person born abroad send `country` (ISO 3166-1 alpha-2): their code holds the state only, and `place` — the city — is
not compared.
