Invitations
- 3 endpoints
Create invitation#
POST /invitations
Invites someone: to the team (role admin, from the dashboard only, both modes) or as a member in the request's mode (role user: a test key invites a test member). The link is returned, and emailed to the person unless sendEmail is false. A new invitation replaces the open one of the same person and mode. Someone already there with that role and mode → 409 already_joined. An organization sends at most 50 invitations an hour, both modes together (429 rate_limit_exceeded). Answers the invitation and its link (only in this response).
- Auth
- X-API-Key: an admin key
- On-Behalf-Of
- Refused: the admin role is needed
- Idempotency-Key
- Accepted: send one per operation
- Success
- 201
request.sh
curl https://api.qbitflow.app/v2/invitations \ -H "X-API-Key: $QBITFLOW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "email": "seller@example.com", "role": "user", "holdFunds": true, "organizationFeePercent": "10", "redirectUrl": "https://shop.example.com/sellers/welcome" }'# 201: a CreatedInvitationconst created = await client.invitations.create({ email: 'seller@example.com', holdFunds: true, // hold their payments until you trust them (always sent) organizationFeePercent: '10', // your commission on their payments: '0' to '50' % redirectUrl: 'https://shop.example.com/sellers/welcome',});// QBitFlow also emails them a link of their own. They are a member once they accepted (the// member.joined webhook).console.log(`invitation ${created.uuid}: ${created.link}`);created = client.invitations.create( email="seller@example.com", hold_funds=True, # hold their payments until you trust them (always sent) organization_fee_percent="10", # your commission on their payments: "0" to "50" % redirect_url="https://shop.example.com/sellers/welcome",)# The seller exists once they accept: wait for the member.joined webhook.# created.uuid is what member.joined's invitation_uuid names; its user_uuid is the seller.# QBitFlow also emails them a link of their own.print("invitation", created.uuid, "link:", created.link)marketplace/main.go
created, err := client.Invitations.Create(ctx, &qbitflow. CreateInvitationParams{ Email: "seller@example.com", HoldFunds: true, // hold their payments until Members.Trust (always sent) OrganizationFeePercent: "10", // your commission on their payments: "0" to "50" % RedirectURL: "https://shop.example.com/sellers/welcome",})if err != nil { log.Fatal(err) // 409 already_joined for a member}// QBitFlow also emails the seller a link of their own. The seller exists once they// accepted: wait for member.joined.fmt.Println("invitation", created.UUID, "link:", created.Link)$created = $client->invitations->create(new \QBitFlow\Params\CreateInvitationParams( email: 'seller@example.com', holdFunds: true, // hold their payments until you trust them (always sent) organizationFeePercent: '10', // your commission on their payments: "0" to "50" % redirectUrl: 'https://shop.example.com/sellers/welcome',));// QBitFlow also emails them a link of their own.echo "Invitation {$created->uuid}: {$created->link}\n";Body
emailstringRequiredemailmax=254rolestringRequiredadmin (the team, from the dashboard) or user (a member, in the request's mode)
oneof=admin userholdFundsbooleanOptionalMembers, required: true = the organization holds their payments until it trusts them (POST /members/:userUuid/trust); false = paid directly. Not for the team
organizationFeePercentnumberOptionalMembers: the organization's fee on their payments, 0 to 50 % (default 0). Not for the team
percent=50redirectUrlstringOptionalOptional: where the person goes once they accepted (with ?invitationUuid=<uuid>). A convenience: confirm with GET /invitations or the member.joined event, not from the redirect
http_urlmax=2048sendEmailbooleanOptionalOptional, default true: QBitFlow emails the person a link of their own (signing up through it needs no confirmation: it proves the inbox). false: no invitation email, share the returned link yourself (signing up through it emails the person a confirmation first: an account is only created for a confirmed address)
Response
A CreatedInvitation.
14 fields of CreatedInvitation
uuidstringIts only id (a UUIDv7)
testbooleanOptionalThe mode of a member's invitation; absent for a team invitation (the team is mode-free)
emailstringrolestringadmin (the team) or user (a member)
holdFundsbooleanOptionalMembers: true = the organization holds their payments until it trusts them; false = paid directly. Absent for the team
organizationFeePercentnumberOptionalMembers: the organization's fee on their payments ("0": none). Written as organizationFeePercent. Absent for the team
redirectUrlstringOptionalWhere the person goes once they accepted, with ?invitationUuid=<uuid>. Absent when none
expiresAtstringacceptedByUserUuidstringOptionalThe account that accepted; absent until accepted
acceptedAtstringOptionalAbsent until accepted
revokedAtstringOptionalAbsent unless revoked (or replaced by a newer one)
createdAtstringstatusstringComputed when read
One of:
pendingSent, waiting for the personacceptedAccepted: the person joinedrevokedRevoked by the organization (or replaced by a newer one)expiredNot accepted in time (TTL)
linkstringA link to accept, to share in your own onboarding if you prefer (sendEmail false: the only one). Signing up through it emails the person a confirmation first; the emailed link (its own token, never returned) doesn't. Only in this response
Errors
- 429rate_
limit_ exceeded rate limit hit (see below)
- 409idempotency_
key_ in_ use the
Idempotency-Key's first request is still running (idempotency): retry once it answered - 422idempotency_
key_ reused the
Idempotency-Keywas used for another request (idempotency): send a new key - 413request_
too_ large an
Idempotentroute's body over 1 MiB (idempotency) - 409already_
joined inviting someone already in the organization with that role and mode (Invitations)
Also the common codes, as any route (what each means):
- 400validation_failed
- 401unauthorized
- 403forbidden
- 403policy_disabled
- 500internal
- 504timeout
Get invitations#
GET /invitations
A page of the organization's invitations of the request's mode, newest first (default 20, at most 100), optionally with one status. From the dashboard, its team's too (an API key only sees member invitations: the team is managed from the dashboard).
- Auth
- X-API-Key: an admin key
- On-Behalf-Of
- Refused: the admin role is needed
- Idempotency-Key
- Ignored
- Success
- 200
request.sh
curl https://api.qbitflow.app/v2/invitations \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a page of Invitation ({ items, hasMore, nextCursor? })const page = await client.invitations.list({ status: InvitationStatus.Pending });for (const invitation of page.items) { console.log(`${invitation.uuid} ${invitation.email}, expires ${invitation.expiresAt}`);}page = client. invitations. list( status=qbitflow. InvitationStatus. PENDING)for invitation in page.items: print(invitation.uuid, invitation.email, "expires", invitation.expires_at)marketplace/main.go
invitations, err := client.Invitations.List(ctx, &qbitflow.InvitationListParams{ Status: qbitflow. InvitationStatusPending,})if err != nil { log.Fatal(err)}for _, inv := range invitations.Items { fmt.Println(inv.UUID, inv.Email, "expires", inv. ExpiresAt. Format( time. RFC3339))}$page = $client->invitations->list(new \QBitFlow\Params\InvitationListParams( status: \QBitFlow\Enums\InvitationStatus::PENDING));foreach ($page->items as $invitation) { echo "{$invitation->uuid} {$invitation->email}, expires {$invitation->expiresAt->format('Y-m-d')}\n";}Query parameters
limitintegerOptionalPage size: 20 when absent or ≤ 0, at most 100 (a larger one is clamped, not refused)
cursorstringOptionalThe previous page's nextCursor, verbatim; omit for the first page
max=200statusstringOptionalOnly the invitations with this status
oneof=pending accepted revoked expiredOne of:
pendingSent, waiting for the personacceptedAccepted: the person joinedrevokedRevoked by the organization (or replaced by a newer one)expiredNot accepted in time (TTL)
Response
A page: { "items": Invitation[], "hasMore": …, "nextCursor"?: … }. Pass nextCursor back as cursor for the next page; it is absent when hasMore is false.
13 fields of each Invitation
uuidstringIts only id (a UUIDv7)
testbooleanOptionalThe mode of a member's invitation; absent for a team invitation (the team is mode-free)
emailstringrolestringadmin (the team) or user (a member)
holdFundsbooleanOptionalMembers: true = the organization holds their payments until it trusts them; false = paid directly. Absent for the team
organizationFeePercentnumberOptionalMembers: the organization's fee on their payments ("0": none). Written as organizationFeePercent. Absent for the team
redirectUrlstringOptionalWhere the person goes once they accepted, with ?invitationUuid=<uuid>. Absent when none
expiresAtstringacceptedByUserUuidstringOptionalThe account that accepted; absent until accepted
acceptedAtstringOptionalAbsent until accepted
revokedAtstringOptionalAbsent unless revoked (or replaced by a newer one)
createdAtstringstatusstringComputed when read
One of:
pendingSent, waiting for the personacceptedAccepted: the person joinedrevokedRevoked by the organization (or replaced by a newer one)expiredNot accepted in time (TTL)
Errors
The common codes only, as any route (what each means):
Revoke invitation#
DELETE /invitations/
Revokes a pending invitation: its link stops working. The team's are revoked from the dashboard only. Already accepted or revoked → 409. Answers 204.
- Auth
- X-API-Key: an admin key
- On-Behalf-Of
- Refused: the admin role is needed
- Idempotency-Key
- Ignored
- Success
- 204
request.sh
curl -X DELETE "https://api.qbitflow.app/v2/invitations/$INVITATION_UUID" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: an Invitation// Only before it is accepted: its link stops working (204, nothing returned).await client. invitations. revoke( invitationUuid);console.log(`revoked ${invitationUuid}`);client. invitations. revoke( invitation_uuid) # 204: its link stops workingprint("revoked", invitation_uuid)marketplace/main.go
// 204: its link stops working.if err := client.Invitations.Revoke(ctx, invitationUUID); err != nil { log.Fatal(err) // 409 once accepted or revoked}fmt.Println("revoked", invitationUUID)$client->invitations->revoke( $invitationUuid); // a pending invitation only (204)echo "Revoked {$invitationUuid}: its link no longer works\n";Path parameters
uuidstringRequired
Response
No JSON body: the edge cases below say what it answers.
Errors
The common codes only, as any route (what each means):
- 400validation_failed
- 401unauthorized
- 403forbidden
- 403policy_disabled
- 404not_found
- 429rate_limit_exceeded
- 500internal
- 504timeout
Edge cases
The contract's rules for these routes: Invite — POST / · List and revoke · The invited person · Several organizations per account · Test-mode members.
Read all 5
Invite — POST /invitations
- Optional
sendEmail(boolean, defaulttrue): QBitFlow emails the person a link of their own, in QBitFlow's name, stating a member's terms: who pays the fees (the organization's commission on the listed price when itscheckout.customerPaysProcessingFeeis on in the invitation's mode, buyers paying QBitFlow's fee on top; on what remains after QBitFlow's fee when it is off; without a commission, whether QBitFlow's fee comes out of their sales; network fees always the buyer's), then where their share goes (the percent they keep; held until the organization trusts them, or directly to their wallet). That link has its own token, never returned: only the email carries it, so signing up through it proves the inbox and needs no confirmation email.falsesends no invitation email: show or send the returnedlinkyourself (your own onboarding, your own email). The returnedlinkworks either way; a person signing up through it gets the sign-up confirmation email first (§ The invited person: you hold that link too, so an account is only created for a confirmed address). One with an account, or signing in with a provider, gets none. A non-boolean →400validation_failedonsendEmail. - A member (
role: user) is invited in the request's mode.holdFundsis required:trueholds their payments until the organization trusts them (POST /members/:userUuid/trust),falsepays them directly. OptionalorganizationFeePercent(a string,"0"to"50", default"0"). - A team invitation (
role: admin) is mode-free, from the dashboard only (an API key →403), withoutholdFundsororganizationFeePercent(→400validation_failedonrole). Itstest,holdFundsandorganizationFeePercentare absent (a member's invitation always has all three). - Optional
redirectUrl(http_url): where the person goes once they accepted, with?invitationUuid=<uuid>(it was?invitationId=). - The invitation expires after 7 days. A new invitation of the same person and mode replaces the open one (its link stops working). Someone already there with that role and mode →
409already_joined(a live member can still be invited in test). holdFunds: truewithorganizationFeePercent > 0(a marketplace's default): the member's payments go to the organization's wallet, the fee is withheld from what the organization owes them, and a release pays the net (Members and held funds § The organization fee on held funds). Held members sell subscriptions too. The organization fee is only taken on the chains where the organization has a wallet (and held funds need that wallet anyway).- A member →
403; validation →400validation_failed(email,role,holdFunds). - At most 50 invitations an hour per organization, both modes and every credential together (
LimitPerOrganization(invitations)) →429rate_limit_exceededpast it, whether or not it emails the person. A caller refused for its role spends nothing. Idempotency-Key(idempotency): a retried invitation with the same key answers the first one (CreatedInvitation, the same link) and sends no second email.
List and revoke
GET /invitations→200a page (newest first bycreatedAt;limitdefault 20, at most 100;cursor= the previous page'snextCursor) of the organization's invitations of the request's mode, and from the dashboard of its team too: an API key lists member invitations only (role: user), the team being managed from the dashboard. An accepted invitation names who accepted it withacceptedByUserUuid(theirMember.userUuid) andacceptedAt; a revoked one hasrevokedAt(both absent otherwise).?status=pending|accepted|revoked|expiredfilters. Another organization's never appear.DELETE /invitations/:uuid(re-authentication) →204with no body: its link stops working (GET /invitationsshows itrevoked). Already accepted or revoked →409conflict; a team invitation with an API key →403; another organization's or another mode's →404.
The invited person
GET /invitations/preview/:token(public) →200:organizationName, the invitedemail,role,test(absent for the team),status,expiresAt, and a member's terms, shown before they accept:holdFunds,organizationFeePercentandcustomerPaysProcessingFee(absent for the team).customerPaysProcessingFeeis the organization'scheckout.customerPaysProcessingFeein the invitation's mode when the link is opened (not when it was sent):true, buyers pay QBitFlow's fee on top of the price, so the commission is on the full listed price (a $100 sale at 20 %: $20 to the organization, $80 to the member);false, QBitFlow's fee comes out of the sale and the commission is on what remains. Members can't set that setting; a checkout's ownfees.processingFeestill decides for itself. Either link's token works (the returned one and the emailed one). A token that isn't one (not letters and digits, or longer than 128) →400validation_failedontoken; a wrong token →400invalid_invitation.POST /invitations/accept{ "token" }with a session (dashboard-only): the signed-in account must be the invited address's (403invitation_email_mismatchotherwise). →200the session re-signed where they joined (SpaceSession+redirectUrl): their member space of the invitation's mode, or the organization's live space for the team. A member invited to the team becomes an admin. An account that already belongs to other organizations joins this one too (§ Several organizations per account).- An account removed from its organization (no membership left) signs in without an organization (Account and credential § Sessions) and accepts with
POST /invitations/accept(its session reaches it), or signs in with the invitation's token:POST /auth/login{ email, password, invitationToken }→ theLoginwhere they joined, withredirectUrl. The invited address must be the account's (403invitation_email_mismatch). Their former personal space reopens with this invitation's terms, their data still there. - There is no list of the invitations an account received: the link (its token) is how one is previewed and accepted.
- A member joining (accept or sign-up confirmation) publishes
member.joinedto the organization's endpoints, in the mode they joined (Webhook events): the marketplace learns who joined, with theiruserUuid, without polling. A team join publishes none. POST /invitations/sign-up{ token }(public) →200SignUpRequested(message, andconfirmationTokenon the emailed link's path):- Through the emailed link (its token only ever reached the invited inbox): a free address gets
confirmationTokenat once, no second email. Post it to…/sign-up/confirmwith the account's name and password. - Through the returned link (the organization holds it too, so it doesn't prove owning the address): a free address gets a confirmation link by email (
/invitations/sign-up/confirm?token=, valid 24 hours, subject "Confirm your email to join <organization> on QBitFlow"), and noconfirmationToken. - An address with an account, through either link: an email saying so (sign in and accept), and no
confirmationToken, the same answer as an emailed confirmation.
- Through the emailed link (its token only ever reached the invited inbox): a free address gets
The account is only created on confirmation. Capped like every confirmation email (errors): 5 an hour per invitation, 20 an hour per client IP → 429 rate_limit_exceeded.
POST /invitations/sign-up/confirm{ token, firstName, lastName, password }(the token: theconfirmationToken, or the confirmation email's; whoever proved the address chooses their name and password) →201theLoginof the new account where they joined, withredirectUrl. A wrong, expired or used confirmation →400invalid_confirmation_token.- An invitation expired, revoked or already accepted →
400invalid_invitation. - An invitation of a closed organization →
400invalid_invitation: closing an organization revokes its pending invitations, and an acceptance (accept or sign-up confirmation) never brings anyone or any space back into a closed organization, even for an invitation left pending.
Several organizations per account
- An account may belong to several organizations, with a role in each, and own several. Accepting an invitation (
/accept, or a login withinvitationToken) with an account that is already an active member, admin or owner of another organization succeeds like any acceptance: it keeps its other memberships, and its session moves to where it joined. A seller who already uses QBitFlow joins your platform with the same account. - Its spaces in every organization are listed by
GET /account/spaces(andLogin.spaces), and its session switches between them (POST /auth/switch-space, Account and credential § Sessions). Each session acts in one space: one organization's data never shows in another's. - An account removed from its organization belongs to none: it accepts normally (§ The invited person).
Test-mode members
- A test member is a real account too: invited by a test key (or the dashboard in test mode), they accept from a real inbox (the confirmation email) and their test space is theirs. There is no way yet to create accepted test members from a key (for CI); use real addresses you control (
you+seller1@yourdomain.com), invited once and kept.
The codes named here
- 400validation_
failed path/query/body failed binding or validation, or a business rule about one input (a field named in
errors) - 409conflict
a state conflict no more specific code covers
- 429rate_
limit_ exceeded rate limit hit (see below)
- 409already_
joined inviting someone already in the organization with that role and mode (Invitations)