8 min read

Someone 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

SignalUse it for
isNewUser on the sign-in or sign-up resultDeciding where to send the browser right now. True only when that same request created the account.
session.created webhook with firstSignInTelling 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

A signed-in user can change their own metadata through 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:

EventWhen it fires
user.createdAn account was created. data.via says how: password, magic_link, oauth (with data.provider), operator, import, or a billing provider.
session.createdOnce 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.updatedRole, metadata or the verified flag changed. data.changed lists field names, never values.
subscription.trial_startedA subscription entered a trial. data.subscription.trialEndsAt is the end.
subscription.trial_will_endThe trial ends within 3 days. Sent once per subscription and trial end date.
organization.invitation.acceptedSomeone 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.

SettingWhat happens
on_signupThe default. Sent at sign-up, except when requireEmailVerification is on and the address is unverified: then it waits for the first verification.
on_verifiedSent once the address is proven. A magic link or an OAuth provider that vouches for the email counts as proof at sign-up.
offNever 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.com matches that domain only and *.acme.com matches its subdomains only, so list both to admit both.
  • blockedDomains always wins over an allowed domain.
  • blockDisposable refuses 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

Onboard new users and remind trials before they end | Rekey