Merchant teams
The local merchant portal uses server-held OAuth tokens and durable merchant memberships. Integration API keys remain for /v1/**; they are not merchant team identities. Never put an OAuth access token or merchant key in browser JavaScript.
GET /portal/session returns the subject’s active memberships. Requests under /portal/merchants/{merchant} require that merchant ID and its X-Futa-Membership-Version. A revoked membership returns 403; a stale version returns 409 MEMBERSHIP_CHANGED. Payment and refund routes share the merchant’s rate window with integration keys; exhaustion returns 429 RATE_LIMITED.
| Role | Permissions |
|---|---|
| Owner | Payments, refunds (approve, set the threshold), settlements, settlement accounts, team, keys, webhooks, sandbox and request log |
| Admin | As the Owner, without the refund threshold; manages Finance, Developer and Support only |
| Finance | Payments and payment links, refunds, settlements (read) |
| Developer | Payments (read), keys, webhooks, sandbox and request log |
| Support | Payments (read) |
Owner, Admin and Developer also hold sandbox:use from 0.49.0 (sandbox scenarios and runs, and from 0.50.0 the participant status) and requests:read from 0.51.0 (the request log), which are never API key scopes (D-034); see developer controls.
GET /portal/merchants/{merchant}/team (Owner/Admin) lists every membership with last_activity — the member’s latest action the account’s journal shows, null before any — the latest 100 invitations and team audit events. The portal’s team table shows two-factor authentication as on for every member, since each sign-in asks for the app’s code, and every store in the stores column until access per store exists; changing a role, removing a member and cancelling an invitation are confirmed in a dialog. An Owner/Admin creates an invitation with POST …/team/invitations ({email, role}) and an idempotency key; it stays valid seven days (expires_at). The recipient sees it in GET /portal/invitations after signing in with the matching verified email, and POST /portal/invitations/{id}/accept accepts once; accepted after its seven days, it answers 409 INVITATION_EXPIRED, or 409 INVITATION_CLOSED once a later invitation for the merchant has marked it expired. The ID is not a secret capability; no email is sent.
POST …/team/members takes the target subject, its current version and an explicit role; role: null revokes, and revoking or changing a member cancels that person’s pending invitations. A target changed since it was read returns 409 MEMBER_CHANGED (MEMBERSHIP_CHANGED stays the caller’s own context); the same role again returns 409 ROLE_UNCHANGED and revokes nothing. The last Owner is protected (409 LAST_OWNER). A change records before=<role>;after=<role|REVOKED> in the audit chain in the same transaction. Retries keep the original key and body.
From OpenAPI 0.45.0, the merchant API itself enforces recent sign-in on inviting (POST …/team/invitations), revoking an invitation (POST …/team/invitations/{id}/revoke) and changing or removing a member (POST …/team/members), as the portal BFF does (D-027). It checks auth_time against futa.step-up.max-age (five minutes by default, configurable from one to fifteen); exactly the maximum age is accepted, with at most sixty seconds of future clock skew. Missing, invalid, older or further-future auth_time returns 403 STEP_UP_REQUIRED before writes or idempotency consumption. Key issue, rotation and revocation have the same guard; accepting an invitation does not.
The browser uses the same-origin BFF’s HttpOnly session and CSRF token; it never calls bearer endpoints directly. See the journal, notification preferences and developer controls.