Merchant + team
Merchant profile, stats, team members, retail stores, and gift card lookup by gift cards management.
This sub-area covers the merchant's own profile, dashboard stats, team member management, retail store management, and the gift-cards-management card lookup. These routes use a mix of permissions and auth patterns; each is noted per endpoint.
Get merchant profile
GET /api/bff/merchantRequires the portal.information permission (admins bypass). Returns the
merchant's company and brand profile, enriched with KYB/onboarding fields.
Response
If the session user has no associated merchant yet (for example, mid onboarding or invited but not yet linked):
{
"ok": true,
"data": { "company": null, "brand": {}, "slug": null, "webshopUrl": "...", "kyb": null },
"meta": { "workspace": "missing", "pendingTeamInvite": false }
}Otherwise:
{
"ok": true,
"data": {
"company": {
"status": "ACTIVE",
"legalName": "...",
"orgNumber": "...",
"taxNumber": "...",
"country": "...",
"tradeName": "...",
"externalMerchantId": "...",
"timeZone": "UTC",
"registrationNumber": "...",
"vatNumber": "...",
"tradingName": "...",
"currency": "EUR",
"numberOfLocations": 0,
"legalAddress": { "line1": "", "city": "", "state": "", "postalCode": "", "country": "IE" },
"directors": [],
"documents": [],
"idenfyKybStatus": null,
"idenfyCompanyId": null,
"idenfySessionUrl": null
},
"brand": {
"logoUrl": "...",
"category": "",
"descriptor": "",
"website": "...",
"facebook": "",
"twitter": "",
"instagram": ""
},
"slug": "...",
"webshopUrl": "...",
"kyb": {
"registrationNumber": "...",
"vatNumber": "...",
"legalAddress": { },
"directors": [],
"documents": []
}
}
}company.status is the merchant's MerchantStatus enum value (DRAFT,
plus others used by onboarding/admin review). orgNumber and taxNumber
both currently mirror vatNumber/registrationNumber; this is a quirk of
the current mapping, not two independently meaningful fields.
If the merchant has no logoUrl set and card-network credentials are
configured, a logo is fetched from the card network and cached
asynchronously after the response is sent; the first response after a
merchant is created may not yet include it.
Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 403 | Non-admin session user's role lacks the portal.information permission. |
| 500 | Unexpected internal error. |
Update merchant profile
PUT /api/bff/merchantRequires the portal.information permission (admins bypass). Updates the
local profile immediately; mirrors changes to the card network in the
background (best-effort, does not block or fail the request).
Request body
{
"company": {
"status": "string",
"legalName": "string",
"orgNumber": "string",
"taxNumber": "string",
"country": "string",
"currency": "string",
"tradeName": "string",
"externalMerchantId": "string",
"timeZone": "string"
},
"brand": {
"logoUrl": "string (valid URL)",
"category": "string",
"descriptor": "string",
"website": "string",
"facebook": "string",
"twitter": "string",
"instagram": "string"
}
}Both company and brand are optional, as are all of their fields. Of
these, only legalName/tradeName (mapped to the merchant's name),
orgNumber (mapped to registrationNumber), taxNumber (mapped to
vatNumber), website (mapped to websiteUrl), logoUrl, and timeZone
(stored in metadata.portalTimeZone) are actually persisted locally.
country and currency are onboarding-owned fields and are not updated
through this endpoint despite being accepted in the request shape.
An account-change notification email is sent to the user best-effort on a successful save.
Response
{ "ok": true, "data": { } }data echoes back the parsed request body, not the updated merchant
record.
Errors
| Status | Cause |
|---|---|
| 400 | Body fails schema validation (for example, brand.logoUrl not a valid URL). |
| 401 | No session. |
| 403 | Non-admin session user's role lacks the portal.information permission. |
| 500 | Save failed; message is a generic "could not save changes" rather than the underlying error detail. |
Get merchant stats
GET /api/bff/merchant/statsCurrently disabled, returns hardcoded zeros
The real analytics query for this endpoint is disabled in source
("disabling real analytics query due to memory risks") and always returns a
fixed zero-valued payload regardless of merchant or the days query
parameter, which is accepted but otherwise unused. Do not rely on this
endpoint for real numbers; use
Analytics overview instead,
which is fully implemented.
Requires only a valid session and a merchant link; no specific permission is checked.
Response
{
"ok": true,
"data": {
"series": [],
"totals": {
"totalRevenue": 0,
"totalOrders": 0,
"totalVisits": 0,
"webshopRevenue": 0,
"widgetRevenue": 0
}
}
}Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 403 | Session user has no associated merchant. |
| 500 | Unexpected internal error. |
List team members and pending invites
GET /api/bff/merchant/usersNo specific permission check beyond having a session and an associated merchant; returns an empty list if the session user has no merchant.
Response
{
"ok": true,
"data": [
{
"id": "user-id-or-invite:challenge-id",
"firstName": "...",
"lastName": "...",
"email": "...",
"phonePrefix": null,
"phone": null,
"role": "Admin",
"status": "Active",
"merchantTeamRole": "ADMIN"
}
]
}The list merges actual team members with pending invite challenges (an
invite already accepted by a matching member email is excluded to avoid
duplicates). id is prefixed invite: for pending invites, otherwise it's
the user's ID. role is a display label: "Admin", "Full access",
"Viewer (read-only)", or "—" for members; for invites it's the invited
role label. status for members is "Active" or "Pending email verification"; for invites it's one of "Pending your verification",
"Verification expired", "Invitation expired", or "Invited, pending acceptance". Pending invites are sorted first, then members, alphabetically
by email within each group.
Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 500 | Unexpected internal error. |
Add a team member
POST /api/bff/merchant/usersAlways returns 400
This endpoint cannot add a user directly. It always returns a fixed error directing the caller to the dashboard's Organization > Team > "Invite teammate" flow, which uses a separate OTP-verification process not exposed as a BFF endpoint.
Response
{
"ok": false,
"error": "New users cannot be added through this API. In the portal, open Organization, then Team, and use Invite teammate. You must confirm the verification code sent to your email before we contact the invitee.",
"code": "TEAM_INVITE_OTP_REQUIRED"
}Status 400 always.
Change a team member's role
PATCH /api/bff/merchant/usersRequires the acting user to have the merchant.team.invite permission
(distinct from the portal.* permissions used elsewhere; effectively
restricted to organization admins).
Request body
| Field | Type | Required |
|---|---|---|
targetUserId | string | yes |
merchantTeamRole | string, one of ADMIN, FULL, VIEWER | yes |
Response
{ "ok": true }Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 403 | Acting user lacks merchant.team.invite permission. |
| 400 | Missing/invalid targetUserId or merchantTeamRole; caller tried to change their own role; or the change would leave the organization with no admin-capable member (a user with merchantTeamRole of ADMIN or unset is "admin-capable", and the last one cannot be downgraded). |
| 404 | targetUserId does not belong to the acting user's merchant. |
| 500 | Unexpected internal error. |
List retail stores
GET /api/bff/merchant-storesRequires the portal.organization permission. Lists the merchant's retail
stores directly from the card network (not a local table).
Failures are swallowed, not surfaced
If the upstream card network call fails or the merchant has no card-network
account configured, this still returns 200 with an empty retailStores
array rather than an error status. A client cannot distinguish "no stores"
from "lookup failed" from this response alone.
Response
{
"ok": true,
"data": {
"merchantId": "...",
"retailStores": [
{
"address1": "...",
"address2": "...",
"city": "...",
"postcode": "...",
"country": "...",
"externalId": "...",
"cashRegisters": 0
}
]
}
}Each store object also includes whatever other fields the card network returned (the normaliser only adds/overwrites the address and identifier fields shown above; it does not strip the rest of the raw response).
Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 404 | Session user has no associated merchant. |
| 403 | Session user's role lacks the portal.organization permission. |
Create a retail store
POST /api/bff/retail-storeRequires the portal.organization permission.
Request body
| Field | Type | Notes |
|---|---|---|
name | string | |
address1 / address2 | string | |
city | string | |
postcode | string | |
country | string | Defaults to US upstream if omitted. |
externalId | string | If omitted, a generated ID (<merchantId>-<timestamp>) is used. |
No schema validation is enforced; fields are read directly from the request body.
Response
{ "ok": true, "data": { } }data is the raw card network response for the created store.
Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 404 | Session user has no associated merchant. |
| 403 | Session user's role lacks the portal.organization permission. |
| 400 | Merchant has no card-network account configured. Response shape here is a bare { "error": "..." }, not { "ok": false, ... }. |
| varies | Upstream creation failure passes through the upstream status code, response shape { "error": "...", "details": { } }. |
| 500 | Unexpected internal error; response shape { "error": "..." }. |
Update a retail store
PUT /api/bff/retail-storeRequires the portal.organization permission. Same request body as create,
plus:
| Field | Type | Notes |
|---|---|---|
externalId | string | Required in practice; identifies which store to update. |
status | string | "Active" maps to upstream ACTIVE; anything else maps to NOT_ACTIVE. |
Response and errors
Same shape as Create a retail store.
Look up a gift card by QR code (gift cards management)
GET /api/bff/cards/{qr}Requires the portal.gift_cards permission. This is the gift-cards
management screen's card lookup, distinct from the POS terminal's lookup at
pos/card/{qrcode} (gated by portal.pos instead); both proxy the same
underlying card network endpoint. See
POS: look up a card by QR code
for the equivalent POS-side contract; the request/response shapes are the
same here.
Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 404 | Session user has no associated merchant. |
| 403 | Session user's role lacks the portal.gift_cards permission. |
| 400 | Missing qr path parameter. |
| varies | Card not found or other upstream failure; passes through the upstream status code. |