Portal Users

Create or update the portal logins your B2B customers use to sign in, and attach each one to the companies (and addresses) it is allowed to operate on. The endpoint takes an array of user records, validates it, enqueues a background job, and returns a job_id. Users are matched on username, so re-sending the same feed updates the existing accounts.

Authentication

Both endpoints accept either a tenant API key or a B2B portal session cookie.

Accepted credentials
FieldTypeRequiredDescription
API keyheadersOptionalx-auth-method: api-key + x-api-key-id: ak_... + x-api-secret: sk_... — the route requires the portal-users permission, which is not offered in the key-creation UI, so in practice the key needs Full Access (*). A key scoped to customers alone gets 403 here.
Session cookiecookieOptionalvinc_b2b_session — set by the B2B portal login flow.

Endpoints

POST/api/b2b/portal-users/import/apiauth: api-key
GET/api/b2b/portal-users/import/api/{job_id}auth: api-key

Import portal users

POST/api/b2b/portal-users/import/apiauth: api-key

Queues an array of users for upsert. Returns 202 Accepted immediately; the write happens in a background worker that bcrypt-hashes any plaintext passwords.

Constraints

  • 1 ≤ users.length5000 per request.
  • Every entry must carry a non-empty username.
  • Recommended batch size: ≤ 100 users per call — password hashing is the bottleneck. Split larger feeds into multiple requests sharing a batch_metadata.batch_id.

Request body

FieldTypeRequiredDescription
usersPortalUserImportItem[]RequiredArray of user payloads — see the field table below.
merge_mode'replace' | 'partial'OptionalBoth modes only touch the fields you send; arrays such as customer_access are replaced wholesale when present. Use 'partial' to be explicit.(default: 'replace')
channelstringOptionalDefault channel for users created in this batch. A per-user channel takes priority. Falls back to the tenant default channel.
batch_metadataobjectOptionalOptional { batch_id, batch_part, batch_total_parts, batch_total_items } — persisted on the ImportJob so multi-part feeds can be correlated.

PortalUserImportItem

User fields
FieldTypeRequiredDescription
usernamestringRequiredLogin identifier — normally the email. Lower-cased and trimmed. Used as the upsert key.
emailstringRequiredContact email. Lower-cased and trimmed. Must be unique within the tenant — a clash rejects that row.
passwordstringOptionalPlaintext password; hashed with bcrypt server-side. Required when creating a new user; omit on update to keep the current password.
is_activebooleanOptionalWhether the account can sign in.(default: true)
channelstringOptionalChannel for this user. Overrides the batch-level channel.
customer_accessImportCustomerAccess[]OptionalWhich companies — and which of their addresses — this user may act on. Replaced wholesale when provided. See below.

ImportCustomerAccess

customer_access entry
FieldTypeRequiredDescription
customer_external_codestringOptionalThe company's external_code (from the companies import). Resolved to the internal customer id server-side. Provide this or customer_id.
customer_idstringOptionalThe internal customer id, if you already have it. Takes priority over customer_external_code.
address_access'all' | string[]Required'all' to allow every address of the company. An array restricts access, but its entries are matched against the internal address_id VINC generates during the company import — not your address external_code. Since an external system has no way to know those ids at import time, send 'all' and narrow the scope from the back office.

Response — 202 Accepted

FieldTypeRequiredDescription
successbooleanOptionaltrue when the batch was accepted and queued.
job_idstringOptionalImportJob id, prefix 'pu_import_'. Poll it for progress and per-row errors.
totalintegerOptionalNumber of users queued.
merge_mode'replace' | 'partial'OptionalEcho of the mode applied.
messagestringOptionalHuman-readable confirmation.

400 is returned for an empty / oversized users array, an invalid merge_mode, or a user missing username (the error names the offending index).

Example

curl — upsert two users
curl -X POST https://cs.vendereincloud.it/api/b2b/portal-users/import/api \
-H "Content-Type: application/json" \
-H "x-auth-method: api-key" \
-H "x-api-key-id: ak_acme_live_1234" \
-H "x-api-secret: sk_live_abcdef..." \
-d '{
  "merge_mode": "partial",
  "channel": "vinc-b2b",
  "users": [
    {
      "username": "info@acme.it",
      "email": "info@acme.it",
      "password": "S0me-Strong-Secret",
      "is_active": true,
      "customer_access": [
        { "customer_external_code": "000123", "address_access": "all" },
        { "customer_external_code": "000124", "address_access": "all" }
      ]
    },
    {
      "username": "buyer@beta-impianti.it",
      "email": "buyer@beta-impianti.it",
      "is_active": false
    }
  ]
}'
202 Accepted
{
"success": true,
"job_id": "pu_import_1714142400000_z9k1m2",
"total": 2,
"merge_mode": "partial",
"message": "Queued 2 portal users for import"
}

Linking one user to several companies

Send a single user object carrying every company in its customer_access array. Rows are processed one by one in the order received, with no de-duplication: two rows sharing an email under different usernames make the second fail with Email already used by another user, and two rows sharing a username make the second overwrite the first — customer_access is replaced, not merged.

One login, two companies
{
"users": [
  {
    "username": "agent@group.it",
    "email": "agent@group.it",
    "password": "init-pass",
    "customer_access": [
      { "customer_external_code": "000123", "address_access": "all" },
      { "customer_external_code": "000456", "address_access": "all" }
    ]
  }
]
}

Check job status

GET/api/b2b/portal-users/import/api/{job_id}auth: api-key

Returns the ImportJob created by the POST above. Poll until status is completed or failed.

FieldTypeRequiredDescription
job.job_idstringOptionalEchoes the requested id.
job.status'pending' | 'processing' | 'completed' | 'failed'OptionalLifecycle state.
job.total_rowsintegerOptionalUsers in the batch.
job.processed_rowsintegerOptionalUsers processed so far.
job.successful_rowsintegerOptionalUsers created or updated.
job.failed_rowsintegerOptionalUsers rejected — see import_errors.
job.import_errors{ row, entity_code, error, raw_data }[]OptionalPer-row failures with the offending payload (first 1000).
job.batch_id / batch_part / batch_total_partsstring / integerOptionalEchoed from batch_metadata when supplied.
job.started_at / completed_at / duration_secondsstring / numberOptionalProcessing timing.
curl — poll the job
curl https://cs.vendereincloud.it/api/b2b/portal-users/import/api/pu_import_1714142400000_z9k1m2 \
-H "x-auth-method: api-key" \
-H "x-api-key-id: ak_acme_live_1234" \
-H "x-api-secret: sk_live_abcdef..."
200 OK
{
"success": true,
"job": {
  "job_id": "pu_import_1714142400000_z9k1m2",
  "status": "completed",
  "total_rows": 2,
  "processed_rows": 2,
  "successful_rows": 2,
  "failed_rows": 0,
  "import_errors": [],
  "started_at": "2026-04-24T13:00:00.500Z",
  "completed_at": "2026-04-24T13:00:01.300Z",
  "duration_seconds": 0.8,
  "batch_id": null,
  "created_at": "2026-04-24T13:00:00.000Z"
}
}

A 404 means no portal_user_import job exists with that id for the authenticated tenant.