> ## Documentation Index
> Fetch the complete documentation index at: https://vexioncards.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a team member

> Adds a staff account. Scope: `team_members:write`. Pick the flow with `mode`:

| | `mode: "invite"` (default) | `mode: "create"` |
|---|---|---|
| How it works | Invitation email with an accept link - the member confirms on your branded accept page and the account activates | The account exists immediately - the member signs in right away with their email code (OTP) or social login |
| Starts as | `status: "invited"`, `origin: "invitation"` | `status: "active"`, `origin: "direct"` |
| Built for | Real people with their own inbox | Till/scanner accounts you provision for a store |

On activation the location assignment is written both to VEXiON and to the member's auth-time enforcement metadata - the same mechanism the scanner app and every location-scoped request checks.

## Location rules (enforced)

- `scanner` → `location_ids` required, **exactly one** - a till account belongs to one store (`422 scanner_requires_single_location`)
- `manager` → one or more; omitted = the organization's default location
- `admin` → must omit `location_ids` - admins implicitly see all locations (`422 admin_has_all_locations`)

## Temporary staff (scanner only)

Set `expires_at` on a scanner for seasonal help: 24 h before expiry the member gets a heads-up email; at expiry all sessions are revoked (signed out within ~5 minutes), access is removed, `status` flips to `expired`, and a confirmation email goes out. Other roles → `422 expires_at_scanner_only`.

## Not on the API

- **Revoking members** - dashboard only; the API reads `status: "revoked"` records
- **Delegated billing** - dashboard only (needs the manager's own billing consent)

Duplicate email (member or pending invite) → `409 already_member`.

## Email validation

Every address is validated by our identity provider before an account or an invitation is created, in **both** modes, so `mode: "invite"` and `mode: "create"` return the same status for the same address.

- **Reserved and unroutable domains are refused.** `@example.test`, `@something.invalid` and similar return `422 validation_failed` with `field_errors[0] = { param: "email", code: "invalid_format" }`. Use a real, routable domain when you test.
- **An address that already holds an account or a pending invitation** returns `409 already_member`.
- **An unrecognised upstream failure** (a provider outage rather than a bad address) returns `500` and is alerted on internally. Only known validation failures are reported back to you as your input, so an outage is never silently blamed on your request. Retry with the same `Idempotency-Key`.



## OpenAPI

````yaml /openapi.json post /v1/team_members
openapi: 3.1.0
info:
  title: VEXiON cards Public API
  description: >-
    ## Rate limiting


    Two independent layers, checked in this order:


    1. **Per-IP throttle (pre-authentication).** Applied to every `/v1` request
    BEFORE the API key is checked, keyed on the caller's IP address:
       - **600 requests/minute** per IP - a flood ceiling, `429 rate_limit_error` / `rate_limited`.
       - **20 failed authentications per 15 minutes** per IP - a key-guessing ceiling. Only responses that are `401` (missing/invalid/revoked key) count against this ceiling; a `403` from a valid key that simply lacks a scope does not. `429 rate_limit_error` / `too_many_failed_attempts`.

       Both ceilings send `Retry-After` (seconds).
    2. **Per-key, per-bucket limiter (post-authentication).** Documented per
    endpoint (see each operation's `429` response and the `rate_limit` field on
    the API Key resource) - e.g. 60/min default, 120/min for transactions, plus
    the notification-broadcast and lookup-filter buckets described on their own
    endpoints.


    ### Rate-limit response headers


    Every rate-limited response carries the legacy `X-RateLimit-Limit` /
    `X-RateLimit-Remaining` / `X-RateLimit-Reset` family (the last is an **epoch
    timestamp**) AND the IETF standards-track headers, side by side:


    - `RateLimit-Policy: "<bucket>";q=<quota>;w=<window seconds>` - the static
    policy for the bucket that was hit.

    - `RateLimit: "<bucket>";r=<remaining>;t=<seconds until reset>` - live
    state. **`t` is a DURATION (seconds remaining), not the epoch timestamp
    `X-RateLimit-Reset` carries - the two are never the same value.**
  version: 1.0.0
servers:
  - url: https://api.vexioncards.one
    description: Production
  - url: https://migration.api.vexioncards.one
    description: Sandbox
security:
  - apiKey: []
tags:
  - name: Customers
  - name: Cards
  - name: Transactions
  - name: Notifications
  - name: Locations
  - name: Products
  - name: API keys
  - name: Organization
  - name: Webhook endpoints
  - name: Events
  - name: Custom fields
  - name: Team members
  - name: Icons
paths:
  /v1/team_members:
    post:
      tags:
        - Team members
      summary: Create a team member
      description: >-
        Adds a staff account. Scope: `team_members:write`. Pick the flow with
        `mode`:


        | | `mode: "invite"` (default) | `mode: "create"` |

        |---|---|---|

        | How it works | Invitation email with an accept link - the member
        confirms on your branded accept page and the account activates | The
        account exists immediately - the member signs in right away with their
        email code (OTP) or social login |

        | Starts as | `status: "invited"`, `origin: "invitation"` | `status:
        "active"`, `origin: "direct"` |

        | Built for | Real people with their own inbox | Till/scanner accounts
        you provision for a store |


        On activation the location assignment is written both to VEXiON and to
        the member's auth-time enforcement metadata - the same mechanism the
        scanner app and every location-scoped request checks.


        ## Location rules (enforced)


        - `scanner` → `location_ids` required, **exactly one** - a till account
        belongs to one store (`422 scanner_requires_single_location`)

        - `manager` → one or more; omitted = the organization's default location

        - `admin` → must omit `location_ids` - admins implicitly see all
        locations (`422 admin_has_all_locations`)


        ## Temporary staff (scanner only)


        Set `expires_at` on a scanner for seasonal help: 24 h before expiry the
        member gets a heads-up email; at expiry all sessions are revoked (signed
        out within ~5 minutes), access is removed, `status` flips to `expired`,
        and a confirmation email goes out. Other roles → `422
        expires_at_scanner_only`.


        ## Not on the API


        - **Revoking members** - dashboard only; the API reads `status:
        "revoked"` records

        - **Delegated billing** - dashboard only (needs the manager's own
        billing consent)


        Duplicate email (member or pending invite) → `409 already_member`.


        ## Email validation


        Every address is validated by our identity provider before an account or
        an invitation is created, in **both** modes, so `mode: "invite"` and
        `mode: "create"` return the same status for the same address.


        - **Reserved and unroutable domains are refused.** `@example.test`,
        `@something.invalid` and similar return `422 validation_failed` with
        `field_errors[0] = { param: "email", code: "invalid_format" }`. Use a
        real, routable domain when you test.

        - **An address that already holds an account or a pending invitation**
        returns `409 already_member`.

        - **An unrecognised upstream failure** (a provider outage rather than a
        bad address) returns `500` and is alerted on internally. Only known
        validation failures are reported back to you as your input, so an outage
        is never silently blamed on your request. Retry with the same
        `Idempotency-Key`.
      operationId: createTeamMember
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: >-
            Required on every mutating POST. 24h byte-replay; `409
            idempotency_key_conflict` on body mismatch. See
            [Idempotency](/api-reference/idempotency).
          schema:
            type: string
            minLength: 8
            maxLength: 255
          example: 5b1f6a2e-9c4d-4e8a-b7f3-1d2c3e4f5a6b
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TeamMemberCreateRequest'
            examples:
              Invite a manager:
                summary: Default mode - email invite, two stores
                value:
                  email: peter@demo-coffee.example
                  role: manager
                  location_ids:
                    - loc_01HW9R4P2ZK7N8MTX1RVLOCS01
                    - loc_01HW9R4P2ZK7N8MTX1RVLOCS02
                  language: sk
              Create a till account:
                summary: 'mode: create - signs in immediately via OTP'
                value:
                  email: till2@demo-coffee.example
                  role: scanner
                  mode: create
                  first_name: Till
                  last_name: Two
                  location_ids:
                    - loc_01HW9R4P2ZK7N8MTX1RVLOCS01
              Seasonal hire with expiry:
                summary: Scanner-only auto-expiry
                value:
                  email: brigadnik@demo-coffee.example
                  role: scanner
                  location_ids:
                    - loc_01HW9R4P2ZK7N8MTX1RVLOCS02
                  expires_at: 1759269600
      responses:
        '201':
          description: Team member created (invited or active per mode).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TeamMember'
              examples:
                Invited:
                  summary: Invitation sent, pending acceptance
                  value:
                    id: tm_01HW9R6M4XQ2N8MTX1RVTM0003
                    object: team_member
                    email: brigadnik@demo-coffee.example
                    first_name: null
                    last_name: null
                    role: scanner
                    location_ids:
                      - loc_01HW9R4P2ZK7N8MTX1RVLOCS02
                    status: invited
                    origin: invitation
                    expires_at: 1759269600
                    language: sk
                    invited_at: 1751600000
                    activated_at: null
                    created_at: 1751600000
                    updated_at: 1751600000
                Created active:
                  summary: Credentials work immediately
                  value:
                    id: tm_01HW9R6M4XQ2N8MTX1RVTM0001
                    object: team_member
                    email: anna@demo-coffee.example
                    first_name: Anna
                    last_name: Kováčová
                    role: scanner
                    location_ids:
                      - loc_01HW9R4P2ZK7N8MTX1RVLOCS01
                    status: active
                    origin: direct
                    expires_at: null
                    language: sk
                    invited_at: null
                    activated_at: 1750000000
                    created_at: 1750000000
                    updated_at: 1751500000
        '400':
          description: Malformed request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Missing, invalid, or revoked API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Key lacks the required scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Duplicate or idempotency conflict.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Already a member:
                  summary: Email taken or invite pending
                  value:
                    error:
                      type: invalid_request_error
                      code: already_member
                      message: >-
                        peter@demo-coffee.example is already a member or has a
                        pending invitation.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                      param: email
        '413':
          description: >-
            Request body exceeds the 1 MB cap. The body was never parsed, so the
            request had no effect.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: invalid_request_error
                  code: request_too_large
                  message: The request body exceeds the maximum allowed size.
                  doc_url: >-
                    https://vexioncards.mintlify.site/api-reference/errors#request_too_large
                  request_id: req_5f8b2c4a9e1d3
        '422':
          description: Semantically invalid input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Scanner needs one location:
                  summary: Cardinality rule
                  value:
                    error:
                      type: invalid_request_error
                      code: scanner_requires_single_location
                      message: >-
                        role 'scanner' requires location_ids with exactly one
                        entry (a till account belongs to one store).
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                      param: location_ids
                Admin has all locations:
                  summary: Cardinality rule
                  value:
                    error:
                      type: invalid_request_error
                      code: admin_has_all_locations
                      message: >-
                        role 'admin' must not carry location_ids - admins
                        implicitly access all locations.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                      param: location_ids
                Expiry in the past:
                  summary: expires_at must be future
                  value:
                    error:
                      type: invalid_request_error
                      code: expires_at_in_past
                      message: expires_at must be in the future.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                      param: expires_at
                Expiry on a non-scanner:
                  summary: expires_at is scanner-only
                  value:
                    error:
                      type: invalid_request_error
                      code: expires_at_scanner_only
                      message: >-
                        expires_at is available on the scanner role only -
                        managers and admins cannot be temporary.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                      param: expires_at
        '429':
          description: Rate limited - respect Retry-After.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Server error. Retry-safe on idempotent calls.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: api_error
                  code: internal_error
                  message: >-
                    An internal error occurred. The request may be retried with
                    the same Idempotency-Key.
                  doc_url: >-
                    https://vexioncards.mintlify.site/api-reference/errors#internal_error
                  request_id: req_5f8b2c4a9e1d3
              examples:
                Http 500 default:
                  summary: Http 500 default
                  value:
                    error:
                      type: api_error
                      code: internal_error
                      message: >-
                        An internal error occurred. The request may be retried
                        with the same Idempotency-Key.
                      doc_url: >-
                        https://vexioncards.mintlify.site/api-reference/errors#internal_error
                      request_id: req_5f8b2c4a9e1d3
          headers: {}
      deprecated: false
components:
  schemas:
    TeamMemberCreateRequest:
      type: object
      description: >-
        Adds a staff account. `mode` picks the flow:

        - `invite` (default) - invitation email with an accept link; `status:
        "invited"` until the member accepts

        - `create` - the account exists immediately (`status: "active"`); the
        member signs in right away with their email code (OTP) or social login.
        Built for till/scanner accounts.


        Delegated billing is never configurable via API.
      required:
        - email
        - role
      properties:
        email:
          type: string
          format: email
          description: >-
            Sign-in email, unique per organization. Already a member or already
            invited → `409 already_member`.
        role:
          type: string
          enum:
            - admin
            - manager
            - scanner
          description: Role. Determines the `location_ids` cardinality rule.
        mode:
          type: string
          enum:
            - invite
            - create
          default: invite
          description: >-
            `invite` - email invitation; the member accepts the link and the
            account activates. `create` - active immediately; the member signs
            in with their email code (OTP) or social login, no acceptance step.
            Defaults to `invite`.
        first_name:
          type: string
          maxLength: 100
          description: >-
            Given name (useful with `mode: "create"` - invited members fill
            their own).
        last_name:
          type: string
          maxLength: 100
          description: Family name.
        location_ids:
          type: array
          items:
            type: string
            pattern: ^loc_[A-Za-z0-9]+$
          maxItems: 100
          description: >-
            Role-bound: `scanner` → REQUIRED, exactly one (`422
            scanner_requires_single_location`); `manager` → one or more,
            defaults to the organization's default location when omitted;
            `admin` → must be omitted (`422 admin_has_all_locations`).
        expires_at:
          type:
            - integer
            - 'null'
          description: >-
            Auto-expiry for temporary staff - **scanner role only** (`422
            expires_at_scanner_only`): seasonal hires, one-day events. Must be
            in the future (`422 expires_at_in_past`). At expiry: sessions
            revoked (~5 min), access removed, `status: "expired"`, confirmation
            email; heads-up email 24 h before. Omit or null = permanent.
        language:
          type: string
          enum:
            - en
            - sk
            - cs
            - de
          description: Email language. Defaults to the organization language.
      example:
        email: till2@demo-coffee.example
        role: scanner
        mode: create
        first_name: Till
        last_name: Two
        location_ids:
          - loc_01HW9R4P2ZK7N8MTX1RVLOCS01
    TeamMember:
      type: object
      description: >-
        A staff account - dashboard and scanner access. Role lives in the auth
        provider; the location assignment is dual-written: a queryable mirror in
        VEXiON (surfaced as `Location.team_members[]`) plus auth-time
        enforcement metadata on the user account, which is what locks a scanner
        to their single store at every request. `location_ids` here is the ONE
        write path - both copies sync from it.


        `id` and this resource's identity fields
        (`first_name`/`last_name`/`email`/`language`) are anchored to a registry
        row scoped to YOUR organization - the same person staffing another
        organization has a DIFFERENT `id` there, and a PATCH here never changes
        what that other organization sees for them. Manual transactions carry
        the acting member as `Transaction.team_member_id` - which is why revoked
        and expired members are preserved as records: history stays attributable
        forever. Members sign in with their email code (OTP) or social login.
      required:
        - id
        - object
        - email
        - role
        - location_ids
        - status
        - origin
        - created_at
      properties:
        id:
          type: string
          pattern: ^tm_[A-Za-z0-9]+$
          description: >-
            Team member identifier, `tm_` prefix. For an `active` member it is
            stable within your organization across role and location changes,
            and it is never shared with any other organization they may also
            staff. For a member still `invited` it belongs to the current
            invitation and changes when the invitation is re-sent, when a PATCH
            edits the invited member's name, role or expires_at (the invitation
            is re-issued and a new email sent), and when the invite is accepted;
            the previous id then returns 404. Key invited members by `email`.
          example: tm_01HW9R6M4XQ2N8MTX1RVTM0001
        object:
          type: string
          const: team_member
          description: Always `"team_member"`.
        email:
          type: string
          format: email
          description: Sign-in email, unique per organization.
          example: anna@demo-coffee.example
        first_name:
          type:
            - string
            - 'null'
          description: >-
            Given name. Null until the member fills their profile (invitation
            origin) or you supply it (direct origin).
          example: Anna
        last_name:
          type:
            - string
            - 'null'
          description: Family name.
          example: Kováčová
        role:
          type: string
          enum:
            - admin
            - manager
            - scanner
          description: >-
            What they can do:

            - `admin` - everything incl. billing, cards, team; implicit access
            to ALL locations

            - `manager` - customers, notifications, scanner at assigned
            locations; no billing, no card editing

            - `scanner` - POS scanner only, at exactly one location (a till
            account)


            Role changes email the member automatically.
          example: scanner
        location_ids:
          type: array
          items:
            type: string
            pattern: ^loc_[A-Za-z0-9]+$
          description: >-
            Assigned locations - **the write path for the location↔member
            relationship** (Location.team_members[] is the read mirror).
            Cardinality is role-bound: `admin` → always `[]` (all locations
            implicit); `manager` → one or more; `scanner` → exactly one.
          example:
            - loc_01HW9R4P2ZK7N8MTX1RVLOCS01
        status:
          type: string
          enum:
            - invited
            - active
            - revoked
            - expired
          description: >-
            Lifecycle:

            - `invited` - invitation email sent, account not yet created
            (invitation origin only)

            - `active` - can sign in and act

            - `revoked` - removed by an admin (DELETE); sessions revoked, record
            preserved

            - `expired` - `expires_at` passed; sessions revoked automatically,
            record preserved


            Terminal states (`revoked`, `expired`) are not editable - add the
            person again with a new POST.
          example: active
        origin:
          type: string
          enum:
            - invitation
            - direct
          description: >-
            How the account came to be: `invitation` - the member accepted an
            email invite on the branded accept page; `direct` - an API call
            created the account, active immediately with email-code (OTP) or
            social sign-in (typical for till/scanner accounts).
          example: direct
        expires_at:
          type:
            - integer
            - 'null'
          description: >-
            Unix seconds when this member auto-expires - the temporary-staff
            feature, **available on the scanner role only** (seasonal hires,
            one-day events; a till account that cleans itself up). Null =
            permanent, and always null for admin/manager. At expiry the platform
            revokes all sessions (signed out within ~5 minutes), removes access,
            sets `status: "expired"`, and emails the member; a heads-up email
            goes out 24 h before. Extend or clear by PATCHing before expiry.
          example: null
        language:
          type:
            - string
            - 'null'
          enum:
            - en
            - sk
            - cs
            - de
            - null
          description: >-
            Email language for invitations and lifecycle notices. Defaults to
            the organization language.
          example: sk
        invited_at:
          type:
            - integer
            - 'null'
          description: When the invitation email was sent. Null for `direct` origin.
          example: 1750000000
        activated_at:
          type:
            - integer
            - 'null'
          description: >-
            When the account became active - invitation acceptance time, or
            creation time for `direct` origin. Null while `invited`.
          example: 1750003600
        created_at:
          type: integer
          description: Unix seconds.
          example: 1750000000
        updated_at:
          type: integer
          description: Unix seconds of last change.
          example: 1751500000
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - type
            - code
            - message
          properties:
            type:
              type: string
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - not_found_error
                - rate_limit_error
                - idempotency_error
                - api_error
            code:
              type: string
            message:
              type: string
            retry_after:
              type: integer
              description: >-
                Seconds to wait before retrying. Present on rate-limit (429) and
                idempotency-key-in-use (409) errors; mirrors the `Retry-After`
                HTTP header. Absent on error types that carry no retry guidance.
              example: 5
            param:
              type:
                - string
                - 'null'
            request_id:
              type: string
            doc_url:
              type: string
              format: uri
            field_errors:
              type: array
              description: >-
                Per-field errors on a 422. At most 100 entries: a request with
                more errors gets the first 100.
              items:
                type: object
                properties:
                  param:
                    type: string
                  code:
                    type: string
                  message:
                    type: string
                  expected_currency:
                    type: string
                    description: >-
                      On `currency_mismatch`: the business currency the request
                      must use. Absent on every other field error.
          description: >-
            The error payload: type (category), code (machine-readable), message
            (human-readable), retry_after (seconds, on
            rate-limit/idempotency-in-use errors only), param (offending field),
            request_id, doc_url, field_errors[].
      description: >-
        Standard error envelope. Branch on `error.code`; include
        `error.request_id` in support requests. See the Errors guide.
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >-
        Your secret API key as a Bearer token: `Authorization: Bearer
        vxn_sk_...`. Keys are created and scoped in the dashboard - see
        [Authentication](/api-reference/introduction#authentication).

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.