Durable Endpoints

Durable Endpoints let you add durability to regular HTTP handlers without separate function definitions or event triggers. You wrap your existing API route with inngest.endpoint() and use step.run() inline to get automatic retries and memoization for each step.

This is useful when you want your endpoint to orchestrate multiple operations (like booking a flight, processing a payment, and sending a confirmation) and guarantee that each step completes exactly once, even if the handler crashes or restarts partway through.

How it differs from traditional Inngest

With the traditional approach, you define separate functions triggered by events. Durable Endpoints flip this: you keep your HTTP handlers and make them durable inline.

Traditional InngestDurable Endpoints
Define separate functionsInline in HTTP handlers
Trigger via eventsDirect HTTP calls
inngest.createFunction()inngest.endpoint()
{ event, step } from contextImport step directly
Separate /api/inngest endpointNo separate endpoint needed

Setting up the client

First, configure your Inngest client with the endpoint adapter for your framework:

import { Inngest } from "inngest";
import { endpointAdapter } from "inngest/next";

const inngest = new Inngest({
  id: "my-app",
  endpointAdapter,
});

The endpointAdapter tells Inngest how to interact with your framework's request/response model. Import it from "inngest/next" for Next.js or "inngest/edge" for Bun. This is the only configuration needed, no separate /api/inngest serve endpoint required.

A simple durable endpoint

With the client set up, you can turn any route handler into a durable endpoint. Each step.run() call is memoized: if the handler is re-executed (due to a retry or crash recovery), completed steps are skipped and their previous results are reused.

import { step } from "inngest";
import { inngest } from "@/inngest/client";
import { NextRequest } from "next/server";

export const POST = inngest.endpoint(async (req: NextRequest) => {
  const { userId, data } = await req.json();

  // Step 1: Validate and enrich the data
  const enriched = await step.run("enrich-data", async () => {
    const user = await db.users.find(userId);
    return { ...data, account: user.accountId };
  });

  // Step 2: Process the enriched data
  const result = await step.run("process", async () => {
    return await processData(enriched);
  });

  // Step 3: Send notification
  await step.run("notify", async () => {
    await sendNotification(userId, result);
  });

  return Response.json({ success: true, result });
});

If the handler crashes after "enrich-data" completes but before "process" finishes, Inngest re-invokes the handler. The first step returns its cached result instantly, and execution picks up at the second step. No duplicate work, no lost progress.

Multi-step orchestration with retries

Real-world workflows often involve multiple external systems that can fail independently. Durable Endpoints handle this naturally since each step retries on its own without re-running previous steps.

Let's take the following trip booking demo that searches for flights, reserves a seat, processes payment, and confirms the booking:

The Trip Planner demo illustrates how Durable Endpoints can be used to build complex multi-step API endpoints.

The Durable Endpoint implementation will look as follows:

import { step } from "inngest";
import { inngest } from "@/inngest/client";
import { NextRequest } from "next/server";

export const GET = inngest.endpoint(async (req: NextRequest) => {
  const url = new URL(req.url);
  const origin = url.searchParams.get("origin") || "NYC";
  const destination = url.searchParams.get("destination") || "LAX";
  const date = url.searchParams.get("date") || new Date().toISOString().split("T")[0];

  // Step 1: Search for available flights
  const availability = await step.run("search-availability", async () => {
    const flights = await searchFlights(origin, destination, date);
    return { flights };
  });

  // Step 2: Reserve the best flight
  // If the seat lock times out, Inngest automatically retries this step
  // without re-running the search
  const reservation = await step.run("reserve-flight", async () => {
    const best = availability.flights[0];
    return await reserveSeat(best.flightNumber, "14A");
  });

  // Step 3: Process payment
  const payment = await step.run("process-payment", async () => {
    return await chargeCard(reservation.price, reservation.pnr);
  });

  // Step 4: Confirm the booking
  const confirmation = await step.run("confirm-booking", async () => {
    return await confirmReservation(reservation.reservationId);
  });

  return Response.json({
    success: true,
    trip: { origin, destination, date },
    reservation,
    payment,
    confirmation,
  });
});

Each step in this workflow is independently durable:

  • If the seat reservation fails with a timeout, only that step retries. The flight search results are preserved.
  • If the payment step fails, the reservation is still held and the payment retries without re-reserving.
  • If the entire server crashes after payment succeeds, the handler resumes at the confirmation step.

Running steps in parallel

When steps don't depend on each other, you can run them in parallel using Promise.all(). Each parallel step is still independently durable and retried.

import { step } from "inngest";
import { inngest } from "@/inngest/client";
import { NextRequest } from "next/server";

export const POST = inngest.endpoint(async (req: NextRequest) => {
  const { flightId, hotelId, carId } = await req.json();

  // Book flight, hotel, and car rental in parallel
  // Each step retries independently if it fails
  const [flight, hotel, car] = await Promise.all([
    step.run("book-flight", async () => {
      return await bookFlight(flightId);
    }),
    step.run("book-hotel", async () => {
      return await bookHotel(hotelId);
    }),
    step.run("book-car", async () => {
      return await bookCarRental(carId);
    }),
  ]);

  // Send a single confirmation after all bookings succeed
  await step.run("send-confirmation", async () => {
    await sendTripConfirmation({
      flight,
      hotel,
      car,
    });
  });

  return Response.json({
    success: true,
    bookings: { flight, hotel, car },
  });
});

If the hotel booking fails, it retries independently while the flight and car rental results are preserved. Once all three succeed, the confirmation step runs.

Try it out

The Trip Booker example is a complete Next.js app that demonstrates all of these patterns with a real UI, progress tracking, and intentional failures to show retry behavior in action.