Skip to content

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.