Send new users to onboarding, and remind trials before they end
Blog GuideSomeone signs up. You want them on a setup screen, not an empty dashboard, and you want your CRM, your analytics and your welcome sequence to hear about it. Later, if they are on a trial, you want to remind them before it ends. Rekey 2.2 gives you a signal for each of those moments. This post wires them into a Next.js app with @rekey.dev/nextjs and a webhook handler.
Two signals, for two jobs
| Signal | Use it for |
|---|---|
isNewUser on the sign-in or sign-up result | Deciding where to send the browser right now. True only when that same request created the account. |
session.created webhook with firstSignIn | Telling your other systems, out of band. Also true for the first sign-in of a user an operator created or imported. |
isNewUser is true for password sign-up, a magic link that created the user, and an OAuth callback that created the user. It is false for every other sign-in, including an OAuth identity linked to an account that already existed, and for refresh, organization switches and MFA completion.
Send new users to onboarding
signUp and signIn from @rekey.dev/nextjs/server set the session cookies and return the session with isNewUser on it. They read REKEY_URL and REKEY_SECRET from the environment.
// app/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { signIn, signUp } from '@rekey.dev/nextjs/server';
export async function signUpAction(form: FormData) {
const session = await signUp({
email: String(form.get('email')),
password: String(form.get('password')),
});
redirect(session.isNewUser ? '/onboarding' : '/dashboard');
}
export async function signInAction(form: FormData) {
const outcome = await signIn({
email: String(form.get('email')),
password: String(form.get('password')),
});
if (outcome.kind === 'mfa_required') {
redirect(`/mfa?challenge=${encodeURIComponent(outcome.mfaChallengeToken)}`);
}
redirect(outcome.session.isNewUser ? '/onboarding' : '/dashboard');
}OAuth and magic-link callbacks return the same shape from @rekey.dev/node, so the check is the same line: if (!outcome.mfaRequired && outcome.isNewUser) redirect('/onboarding').
Remember that onboarding is done
isNewUser is true once, on the request that created the account. If someone closes the tab halfway through setup, you need your own record of whether they finished. The user's metadata is a convenient place for it:
// app/onboarding/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { auth } from '@rekey.dev/nextjs/server';
import { Rekey } from '@rekey.dev/node';
const rekey = new Rekey({ apiUrl: process.env.REKEY_URL!, secretKey: process.env.REKEY_SECRET! });
export async function finishOnboarding(form: FormData) {
const session = await auth();
if (!session) redirect('/sign-in');
await rekey.auth.updateCurrentUser(session.accessToken, {
metadata: { team: String(form.get('team')), onboardedAt: new Date().toISOString() },
});
redirect('/dashboard');
}Your dashboard can then send anyone whose session.user.metadata?.onboardedAt is missing back to setup. Metadata is shallow-merged, so this write leaves other keys alone.
Metadata is the user's to edit
PATCH /api/v1/users/me. That is fine for “have they seen the setup screen”. Never decide what someone has paid for, or what they are allowed to do, from a metadata flag. Use entitlements for that.Tell the rest of your stack
Register an endpoint in Panel → Application → Webhooks and pick the events, or * for all of them. The signing secret is shown once, so store it straight away as REKEY_WEBHOOK_SECRET. The events that matter here:
| Event | When it fires |
|---|---|
user.created | An account was created. data.via says how: password, magic_link, oauth (with data.provider), operator, import, or a billing provider. |
session.created | Once per real sign-in, never on refresh or organization switch. data.firstSignIn is true exactly once per user, even when two first sign-ins race. |
user.updated | Role, metadata or the verified flag changed. data.changed lists field names, never values. |
subscription.trial_started | A subscription entered a trial. data.subscription.trialEndsAt is the end. |
subscription.trial_will_end | The trial ends within 3 days. Sent once per subscription and trial end date. |
organization.invitation.accepted | Someone joined a team through an invitation. |
The handler below verifies the signature the way the webhook docs describe: over the raw body, before parsing. It dedupes on eventId, because retries reuse it, and hands the work to a queue so it can answer 2xx quickly. Rekey waits 10 seconds by default and retries anything else.
// app/api/rekey/webhook/route.ts
import { verifyWebhookSignature, type WebhookEventEnvelope } from '@rekey.dev/node';
export const runtime = 'nodejs';
export async function POST(req: Request): Promise<Response> {
const raw = await req.text(); // the raw body, before any parsing
const ok = verifyWebhookSignature({
header: req.headers.get('x-rekey-signature'),
payload: raw,
secret: process.env.REKEY_WEBHOOK_SECRET!,
});
if (!ok) return new Response('bad signature', { status: 401 });
const event = JSON.parse(raw) as WebhookEventEnvelope;
if (await alreadyHandled(event.eventId)) return new Response('ok');
switch (event.type) {
case 'user.created':
await queue('crm.add-contact', event.data); // data.user, data.via
break;
case 'session.created':
if (event.data.firstSignIn === true) await queue('onboarding.start', event.data);
break;
case 'subscription.trial_will_end':
await queue('trial.nudge', event.data); // data.subscription.trialEndsAt
break;
default:
break;
}
return new Response('ok');
}
// Your own storage and job queue.
declare function alreadyHandled(eventId: string): Promise<boolean>;
declare function queue(job: string, data: Record<string, unknown>): Promise<void>;runtime = 'nodejs' matters: verifyWebhookSignature uses Node's crypto module and refuses to run on an edge runtime. A delivery signed more than five minutes from your server's clock fails verification, which is what stops an old captured request being replayed.
Which one to act on? If your welcome sequence should start for every person who can actually use the app, firstSignIn is the better trigger. It covers users your team created by hand, and it waits until they arrive. user.created fires at creation, which suits a CRM that wants every account.
When the welcome email goes out
Rekey's own welcome email now has a setting, authConfig.welcomeEmail, on the panel's Auth page. It is sent once to accounts created by password, magic link or OAuth sign-up. Users your team creates or imports never get it.
| Setting | What happens |
|---|---|
on_signup | The default. Sent at sign-up, except when requireEmailVerification is on and the address is unverified: then it waits for the first verification. |
on_verified | Sent once the address is proven. A magic link or an OAuth provider that vouches for the email counts as proof at sign-up. |
off | Never sent. Pick this when your own sequence does the welcoming. |
If you start your own welcome sequence from session.created, set this to off so people do not get two. The same setting is on PATCH /api/v1/tenant/applications/:id/auth-config.
Remind a trial before it ends
subscription.trial_will_end arrives when a trial has 3 days or less to run. Rekey checks every 10 minutes and sends it once per subscription and trial end date. If the trial is re-dated after the event went out, you get it again for the new date. It is not sent for a trial that already converted or ended.
The queued job can send the reminder through one of your own email templates. Here trial_ending is a template you published with a planName string and a trialEndsOn date:
// jobs/trial-nudge.ts
import { Rekey } from '@rekey.dev/node';
const rekey = new Rekey({ apiUrl: process.env.REKEY_URL!, secretKey: process.env.REKEY_EMAIL_KEY! });
type TrialWillEnd = {
subscription: { id: string; endUserId: string; planName: string; trialEndsAt: string };
};
export async function trialNudge({ subscription }: TrialWillEnd) {
const user = await rekey.users.get(subscription.endUserId);
await rekey.email.send({
template: 'trial_ending',
to: user.email,
variables: {
planName: subscription.planName,
trialEndsOn: subscription.trialEndsAt.slice(0, 10), // declared as a date
},
idempotencyKey: `trial-ending:${subscription.id}:${subscription.trialEndsAt}`,
});
}The idempotency key includes the trial end, so a webhook retry sends nothing twice, while a re-dated trial still gets its own reminder. The key behind REKEY_EMAIL_KEY needs two scopes: auth:read for the user lookup and email:send for the send.
Decide who can sign up at all
Onboarding starts before the first screen: some products only want colleagues from one company, and most do not want throwaway inboxes filling a free tier. In Panel → Application → Auth → Sign-up email rules, or in the same auth-config PATCH:
{
"signupRestrictions": {
"allowedDomains": ["acme.com", "*.acme.com"],
"blockedDomains": ["contractors.acme.com"],
"blockDisposable": true
}
}allowedDomains, when set, is the only list that may sign up.acme.commatches that domain only and*.acme.commatches its subdomains only, so list both to admit both.blockedDomainsalways wins over an allowed domain.blockDisposablerefuses throwaway-inbox domains from a list that ships with the release. Nothing is fetched at runtime.
A refused sign-up answers 403 SIGNUP_EMAIL_DOMAIN_NOT_ALLOWED, with a message you can show as it is. It never names the allowed domains. The rules cover password sign-up, magic links and a first OAuth sign-in. Existing users keep signing in whatever their domain, and users your team creates or imports are not checked.
Reference
- docs/webhooks.md has every event and payload, the retry schedule and signature details.
- docs/auth.md covers
isNewUser,welcomeEmailand the sign-up email rules. - Everything else new in 2.2 is in what changed since the first release candidate.
