Skip to main content

Inviting Users & Auto-Creating ZewstIDs

Invite someone to your app by email from your backend. If they already have a ZewstID, the invite is tied to it; if they don't, ZewstID creates one automatically — then they sign in and land in your app.

This is an M2M flow. You'll need a service account with the

users:invite
scope. New here? Read M2M Authentication first, and the Scopes Catalog for how scopes are granted.

Overview

Your Backend ZewstID Invitee │ POST /m2m/users/invite │ │ │ ────────────────────────────────►│ creates ZewstID if new │ │ │ ─── branded invite email ────► │ │ │ │ clicks link │ │ ◄──────────────────────────────│ │ │ 302 → your invite_accept_uri │ │ (your OAuth sign-in runs) │ │ │ POST /m2m/invites/:token/accept │ │ │ ────────────────────────────────►│ consumes token, returns user │

Prerequisites

  1. Create a service account in the Developer Portal; note its
    client_id
    and
    client_secret
    (the secret is shown once, at creation).
  2. Grant it
    users:invite
    .
    In the portal the scope must show as "in token" — if it appears under a drift warning, click Re-sync scopes.
  3. Register your redirect URLs on the service account:
    • redirect_uri
      — the exact URL ZewstID may return the user to.
    • invite_accept_uri
      — the page in your app that handles acceptance (its origin must match a registered URL).

1. Get an M2M access token

Use the standard token endpoint, form-encoded (not

/api/v1/oauth/token
):

curl -X POST https://api.zewstid.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=client_credentials \ -d client_id=$ZEWSTID_SA_CLIENT_ID \ -d client_secret=$ZEWSTID_SA_CLIENT_SECRET \ --data-urlencode 'scope=users:invite'

Cache and reuse the

access_token
until it nears expiry — don't mint one per invite. A
400 invalid_scope
here means the scope isn't attached to the SA yet; fix it in the portal (prerequisite 2).

2. Send the invite

This is the step that creates a ZewstID when the email has none.

curl -X POST https://api.zewstid.com/api/v1/m2m/users/invite \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "redirect_uri": "https://app.example.com/auth/callback", "invite_accept_uri": "https://app.example.com/invitations/accept", "first_name": "New", "last_name": "Person", "app_name": "Example App", "state": "optional-opaque-value", "metadata": { "role": "tester" } }'
FieldRequiredNotes
email
✅Dedup key. Existing ZewstID → reused; otherwise a new one is created.
redirect_uri
✅Must be a registered redirect URL on the SA, or
400
.
invite_accept_uri
—Where the click lands; its origin must be registered. Falls back to
redirect_uri
.
first_name
/
last_name
—Seeded onto a newly-created user.
app_name
—Branding in the email; defaults to the SA/client name.
state
—Opaque; echoed back on the redirect.
metadata
—Stored on the invite (and merged onto a new user's attributes).

What ZewstID does: looks up the email; if no user exists it creates a ZewstID (passwordless, email pre-verified, tagged

invitedBy=<your SA>
), then mints a single-use invite token (valid 72 hours) and emails a branded invite.

201
response (no token is returned — it's only in the email):

{ "invite_id": "…", "email": "[email protected]", "status": "sent", "user_exists": false, "expires_at": "…Z" }

user_exists: false
means a new ZewstID was created.

3. The user clicks the email → ZewstID redirects to your app

ZewstID handles the click at its own URL, then 302-redirects to your

invite_accept_uri
with query parameters:

https://app.example.com/invitations/accept ?invite_token=<token> &email=<email> &app_name=<app_name> &user_exists=<true|false> &state=<your state, if sent>

The click does not consume the token (that happens in step 5). Optionally, your page may read public invite info first:

GET https://api.zewstid.com/api/v1/invites/<invite_token> → { "email": "…", "app_name": "…", "user_exists": false, "expires_at": "…Z" }

(Public, rate-limited 30/min — no invite id, owner, or metadata exposed.)

4. Authenticate the user with your normal sign-in

From the accept page, run your standard ZewstID OAuth sign-in (see OAuth Integration). A brand-new user establishes how they sign in; an existing user just logs in.

Why not consume the invite directly here? The accept step hands off to your OAuth flow instead of minting a session itself. Bypassing your app's sign-in causes a "state cookie was missing" error. Let your OAuth flow own the session; the invite is reconciled in step 5.

Optionally,

POST /api/v1/invites/<token>/activate
with
{ "password": "…" }
sets a password for the invited user. Treat it as credential setup only — do not infer email verification from it; the normal OAuth / verified-email checks still apply.

5. Consume the invite from your backend

After the user is authenticated, consume the invite with the same service account that created it:

curl -X POST https://api.zewstid.com/api/v1/m2m/invites/<invite_token>/accept \ -H "Authorization: Bearer $ACCESS_TOKEN"

It atomically consumes the token (single-use) and returns the reconciled identity:

{ "invite_id": "…", "email": "…", "user_id": "<ZewstID user id>", "user_exists": false, "app_name": "…", "accepted_at": "…Z", "metadata": { "role": "tester" } }

Persist

user_id
as the link to your app's user record, and confirm it matches the
sub
of the token your OAuth flow just issued.

Existing vs. new users — and the one gotcha

  • Email already a ZewstID → reused (
    user_exists: true
    ); no new identity.
  • Email with no ZewstID → a new identity is created (
    user_exists: false
    ).
  • ⚠️ Dedup is by email only. Inviting the same person under a different email than their existing ZewstID creates a second, duplicate identity — ZewstID can't tell they're the same human. If your users may arrive under multiple emails, normalize/confirm the invited email, or reconcile on your side.

Errors & troubleshooting

SymptomCauseFix
Token
400 invalid_scope
users:invite
not attached to the SA
Portal → the SA → Re-sync scopes
Token
401 invalid_client
bad credentials, or you used
/api/v1/oauth/token
Use
https://api.zewstid.com/oauth/token
; check credentials
Invite
400
redirect/origin
redirect_uri
/
invite_accept_uri
not registered
Register the exact URL(s) on the SA
Invite
403 insufficient_scope
token lacks
users:invite
Re-mint after attaching the scope
Consume fails on 2nd calltoken already consumed (single-use)Expected — don't retry a consumed token
Consume wrong-client errora different SA tried to consumeConsume with the same SA that invited

Security notes

  • The invite token travels only in the email and the redirect — keep it out of logs, analytics, and OAuth
    state
    .
  • Invites expire in 72 hours and are single-use.
  • redirect_uri
    /
    invite_accept_uri
    are validated against the SA's registered URLs — register exactly what you use; never accept client-supplied redirects.
  • Keep the SA
    client_secret
    server-side only; rotate it in the portal if leaked.

Endpoint reference

StepMethod & pathAuth
Token
POST /oauth/token
(form)
client_id
+
client_secret
Send invite
POST /api/v1/m2m/users/invite
Bearer (
users:invite
)
Public invite info
GET /api/v1/invites/:token
none (rate-limited)
Click → your app
GET /api/v1/invites/:token/accept
→ 302
none
(Optional) set password
POST /api/v1/invites/:token/activate
none (rate-limited)
Consume invite
POST /api/v1/m2m/invites/:token/accept
Bearer (
users:invite
), same SA

See also

Was this page helpful?

Let us know how we can improve our documentation