SDK

Webhooks

Receive, verify, and handle Nexa webhook events for file processing, payments, and student email provisioning.

How webhooks work

When a file finishes processing or a payment is confirmed, Nexa sends a signed POST request to a URL you register in the dashboard. The SDK:

  1. Verifies the HMAC-SHA256 signature using your NEXA_WEBHOOK_SECRET (your API key — see setup below)
  2. Rejects events older than 5 minutes (replay protection)
  3. Parses the payload into a typed WebhookEvent
  4. Calls your onFileComplete or onPaymentComplete callback

This happens automatically inside the catch-all route handler for those two events only. Student email events (email.created, email.status_changed, email.bulk_completed) are not auto-dispatched by createNexa() — there's no matching callback field, and the catch-all route has no built-in path for them. See Email events below.


Setup checklist

1. Register your webhook URLs in the Nexa dashboard:

EventURL
file.completedhttps://your-domain.com/api/nexa/webhook
payment.completedhttps://your-domain.com/api/nexa/payments/forward
email.* (created / status_changed / bulk_completed)A route you write yourself, e.g. https://your-domain.com/api/nexa/email-webhook — see below

2. Confirm NEXA_WEBHOOK_SECRET is set in your environment:

NEXA_WEBHOOK_SECRET=nxa_live_...   # same value as NEXA_API_KEY

Info

Nexa signs every webhook with HMAC-SHA256 using your API key as the signing secret. There is no separate webhook secret to generate today — set NEXA_WEBHOOK_SECRET to the same value as NEXA_API_KEY, and update both whenever you rotate the key.

3. Implement your callbacks in lib/nexa.ts:

export const nexa = createNexa({
  apiKey: process.env.NEXA_API_KEY!,
  webhookSecret: process.env.NEXA_WEBHOOK_SECRET!,
  getUser: async () => { /* ... */ },
 
  onFileComplete: async (event) => {
    // called when file.completed arrives
    await db.fileResults.upsert({ fileId: event.fileId, userId: event.userId });
  },
 
  onPaymentComplete: async (event) => {
    // called when payment.completed arrives
    await db.payments.upsert({ reference: event.reference, status: event.status });
  },
});

createNexa() only accepts onFileComplete and onPaymentComplete — there is no onEmailBulkCompleted (or any other email-event) callback.


Manual verification

If you need to verify a webhook in a custom route outside the SDK — required for any email event, optional for file/payment events if you want to handle them yourself instead of using the built-in callbacks:

import { nexa } from "@/lib/nexa";
 
// app/api/custom-webhook/route.ts
export async function POST(request: Request) {
  const event = await nexa.webhooks.verify(request);
  // throws NexaError if signature is invalid or event is too old
 
  // Discriminate on `event.event` — every WebhookEvent variant uses this field, not `type`.
  if (event.event === "file.completed") {
    await handleFileComplete(event);
  }
 
  return Response.json({ received: true });
}

You can also adjust the replay protection window:

const event = await nexa.webhooks.verify(request, { maxAgeSeconds: 600 });

Email events need a custom route

There's no /api/nexa/email-* path in the catch-all route handler and no email-event callback on createNexa(). Registering a URL like /api/nexa/email-provisioning/forward in the dashboard will 404 — write your own route and verify manually:

// app/api/nexa/email-webhook/route.ts
import { nexa } from "@/lib/nexa";
 
export async function POST(request: Request) {
  const event = await nexa.webhooks.verify(request);
 
  switch (event.event) {
    case "email.created":
      await db.studentEmails.upsert({ email: event.email, studentId: event.studentId });
      break;
    case "email.status_changed":
      await db.studentEmails.updateStatus(event.email, event.status);
      break;
    case "email.bulk_completed": {
      // Payload is counts only — pull full per-student results if you need them.
      const details = await nexa.email.getJobStatus(event.jobId);
      if (event.failedCount > 0) {
        await notifyAdmin(details);
      }
      break;
    }
  }
 
  return Response.json({ received: true });
}

Register https://your-domain.com/api/nexa/email-webhook in the dashboard for the email events you use.


WebhookEvent types

Discriminated on event (not type):

type WebhookEvent =
  | WebhookFileCompleteEvent
  | WebhookPaymentEvent
  | WebhookEmailCreatedEvent
  | WebhookEmailStatusChangedEvent
  | WebhookEmailBulkCompletedEvent;

WebhookFileCompleteEvent

interface WebhookFileCompleteEvent {
  event: "file.completed";
  fileId: string;
  userId: string;
  tenantId: string;
  timestamp: string;
}

The event itself carries no result payload or status — it's a signal to go fetch the latest state via nexa.documents.getFileDetails() / nexa.documents.getStudentRecords(), or nexa.documents.getLatestJob() if you need to know whether extraction succeeded or failed.

WebhookPaymentEvent

interface WebhookPaymentEvent {
  event: "payment.completed";
  reference: string;
  tenantId: string;
  status: "success" | "pending" | "failed" | "abandoned";
  amount: number;          // smallest currency unit (kobo for NGN)
  customerEmail: string;
  amountPaid?: number;      // amount actually received after fees
  fees?: number;
  platformFee?: number;
  netAmount?: number;
  paidAt?: string;
  channel?: string;
  failureReason?: string;
  createdAt: number;        // Unix ms — when the transaction was created on Nexa
  updatedAt: number;        // Unix ms — when the transaction was last updated
  timestamp?: string;       // ISO 8601 — when the webhook was delivered (not the same as createdAt)
}

WebhookEmailCreatedEvent

Fired after a single student email account is successfully provisioned.

interface WebhookEmailCreatedEvent {
  event: "email.created";
  tenantId: string;
  studentId: string;
  email: string;
  firstName: string;
  lastName: string;
  gradeLevel?: string;
  provider: "nexa" | "stalwart" | "google";
  providerUserId: string;
  tier: EmailTier;
  createdAt: number; // Unix ms
}

WebhookEmailStatusChangedEvent

Fired when an account is suspended or restored.

interface WebhookEmailStatusChangedEvent {
  event: "email.status_changed";
  tenantId: string;
  email: string;
  studentId: string;
  previousStatus: "active" | "suspended";
  status: "active" | "suspended";
  changedAt: number; // Unix ms
}

WebhookEmailBulkCompletedEvent

Fired once when a bulk provisioning job reaches completed or failed. The payload is lean — counts only. Call nexa.email.getJobStatus(jobId) to retrieve the full per-student breakdown.

interface WebhookEmailBulkCompletedEvent {
  event: "email.bulk_completed";
  tenantId: string;
  jobId: string;
  status: "completed" | "failed";
  totalStudents: number;
  successCount: number;
  failedCount: number;
  completedAt: number; // Unix ms
}

Info

Why no per-student data in the webhook? Pushing individual account details for each student in a 500-student job would make the webhook body extremely large and expensive to receive and process. Instead, the webhook tells you the job is done — you pull the full results only when you need them via nexa.email.getJobStatus(jobId).


Convex integration

@nexa-ed/convex provides pre-built handlers that sync file.completed and payment.completed events straight into your Convex backend, wired the normal way through createNexa():

import { createFileCompleteHandler, createPaymentCompleteHandler } from "@nexa-ed/convex/handlers";
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";
 
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
 
export const nexa = createNexa({
  // ...
  onFileComplete: createFileCompleteHandler(convex, api),
  onPaymentComplete: createPaymentCompleteHandler(convex, api),
});

This writes to the nexaFileResults and paymentTransactions tables defined by @nexa-ed/convex/schema.

For student email events, @nexa-ed/convex also exports createStudentEmailHandler(convex, api) — but since there's no createNexa() callback for email events (see above), call it yourself from the custom route:

// app/api/nexa/email-webhook/route.ts
import { createStudentEmailHandler } from "@nexa-ed/convex/handlers";
import { nexa } from "@/lib/nexa";
import { convex, api } from "@/lib/convex";
 
const handleStudentEmail = createStudentEmailHandler(convex, api);
 
export async function POST(request: Request) {
  const event = await nexa.webhooks.verify(request);
  if (event.event === "email.created" || event.event === "email.status_changed") {
    await handleStudentEmail(event);
  }
  return Response.json({ received: true });
}

Testing webhooks locally

Use ngrok or Cloudflare Tunnel to expose your local server:

ngrok http 3000
# → Forwarding https://abc123.ngrok.io → localhost:3000

Register https://abc123.ngrok.io/api/nexa/webhook in the Nexa dashboard temporarily, then trigger a test event from the dashboard to verify your handler is working.


What's next

On this page