DEV Community

Mohamed Bal
Mohamed Bal

Posted on

Build Secure Async AI Jobs with DEVUP AI Webhooks in Next.js

Disclosure: I build DEVUP AI. This tutorial uses its public Native API and webhook contract. It does not describe private platform infrastructure, and it does not rely on a specific model vendor.

A synchronous AI request is easy to understand: send input, hold the connection open, and wait for output.

That design becomes fragile when a task can take longer than a browser, proxy, serverless function, or mobile connection is willing to wait. Closing the connection does not necessarily mean the work stopped. Retrying blindly can submit the same expensive job twice.

The production-shaped alternative is asynchronous:

  1. Your backend creates a local job.
  2. It submits inference with a callback URL.
  3. DEVUP AI acknowledges the queued request immediately.
  4. Your application receives a signed webhook when the result is ready.
  5. The webhook handler verifies, deduplicates, and persists the result.

In this guide, we will implement that complete path with Next.js Route Handlers, PostgreSQL, HMAC-SHA256 verification, replay protection, idempotent delivery handling, and explicit failure states.

What we are building

The browser never receives an API key and never supplies a webhook URL. Both values remain controlled by the server.

Browser
  │ POST /api/jobs
  ▼
Next.js backend ── submit with fixed callback URL ──► DEVUP AI Native API
  │                                                     │
  │ 202 { jobId, status: "queued" }                     │ signed callback
  ▼                                                     ▼
Browser polls status                         POST /api/webhooks/devupai/:token
                                                           │
                                                           ├─ verify raw body
                                                           ├─ reject stale signature
                                                           ├─ deduplicate delivery ID
                                                           └─ update local job
Enter fullscreen mode Exit fullscreen mode

An asynchronous callback separates initial registration from later execution. The following public-domain diagram presents the same general idea; it is conceptual and not a diagram of DEVUP AI's private implementation.

Conceptual diagram showing an application registering a callback and the platform invoking it asynchronously later

Figure: TuukkaH, “Callback-async-notitle.svg”, public domain. Used unmodified as a general callback illustration.

Understand the public webhook contract first

DEVUP AI webhooks are available through the Native API endpoint:

POST /v1/inference/{model}
Enter fullscreen mode Exit fullscreen mode

They are not enabled by adding a webhook field to OpenAI-compatible endpoints. A Native API request containing a callback URL returns a queued acknowledgement, and the completed result is later sent to that URL through an HTTP POST request.

Each delivery includes:

  • X-DevUp-Signature: timestamp plus one or more HMAC-SHA256 signatures.
  • X-DevUp-Delivery-Id: a stable delivery identifier shared by retries of the same event.
  • A JSON body whose status is either succeeded or failed.

Retryable delivery failures include timeouts, HTTP 408, 429, and 5xx. Other 4xx responses are treated as permanent client errors. Your endpoint should therefore return 2xx quickly after durable persistence and reserve 5xx for genuinely temporary failures. See the current DEVUP AI webhook documentation for the exact delivery contract.

Why the raw request body is non-negotiable

The signature is calculated over:

timestamp + "." + exact raw request bytes
Enter fullscreen mode Exit fullscreen mode

Parsing JSON and then serializing it again may change whitespace, escaping, or key order. The reconstructed bytes are not the bytes that were signed.

Therefore, the safe order is:

read raw bytes → verify signature → parse JSON → validate schema → persist
Enter fullscreen mode Exit fullscreen mode

HMAC combines a secret key with a hash construction to authenticate a message. The diagram below is a general HMAC illustration; the code in this tutorial uses HMAC-SHA256 as required by the DEVUP AI webhook contract.

General HMAC construction diagram showing how a secret key and message produce a message authentication code

Figure: Gdrooid, “SHAhmac.svg”, CC0 1.0, unmodified. The original diagram illustrates the HMAC construction with SHA-1; this tutorial uses SHA-256.

Prerequisites

You need:

  • A Next.js application using the App Router.
  • A PostgreSQL database.
  • A DEVUP AI API key.
  • An exact model ID copied from the live model catalog.
  • A webhook signing secret from the DEVUP AI dashboard.
  • A public HTTPS base URL for your application.

Install the two application dependencies:

npm install postgres zod
Enter fullscreen mode Exit fullscreen mode

Use server-only environment variables:

DEVUP_API_KEY=replace_me
DEVUP_MODEL_ID=exact-model-id-from-the-catalog
DEVUP_WEBHOOK_SECRETS=current-signing-secret
DATABASE_URL=postgresql://...
APP_BASE_URL=https://your-app.example
Enter fullscreen mode Exit fullscreen mode

Do not prefix these variables with NEXT_PUBLIC_. Never place the API key or webhook secret in client code, browser storage, screenshots, logs, or a Git repository.

During a signing-secret rotation, you can temporarily provide both secrets as a comma-separated list:

DEVUP_WEBHOOK_SECRETS=new-secret,previous-secret
Enter fullscreen mode Exit fullscreen mode

The verification code below also accepts any valid v1 value present in the signature header.

Step 1: Create durable job and delivery tables

The callback path contains a random one-time correlation token. We store only its SHA-256 hash. This avoids depending on undocumented fields inside the callback body and prevents the raw token from being recovered from the database.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE ai_jobs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  callback_token_hash text NOT NULL UNIQUE,
  remote_inference_id text UNIQUE,
  status text NOT NULL CHECK (status IN (
    'submitting',
    'queued',
    'succeeded',
    'failed',
    'submission_unknown'
  )),
  result jsonb,
  error jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE webhook_deliveries (
  delivery_id text PRIMARY KEY,
  job_id uuid NOT NULL REFERENCES ai_jobs(id) ON DELETE CASCADE,
  received_at timestamptz NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

webhook_deliveries.delivery_id is the idempotency barrier. If the same delivery is retried, the primary-key constraint prevents the application from applying its result twice.

Create the database client:

// lib/db.ts
import postgres from "postgres";

if (!process.env.DATABASE_URL) {
  throw new Error("DATABASE_URL is required");
}

export const sql = postgres(process.env.DATABASE_URL, {
  max: 10,
  idle_timeout: 20,
});
Enter fullscreen mode Exit fullscreen mode

Use connection settings appropriate for your hosting environment. A serverless deployment will usually require a database connection pooler.

Step 2: Verify signatures without timing leaks

Create a small verifier that:

  • Parses one timestamp and every v1 signature.
  • Rejects malformed values.
  • Rejects timestamps outside a five-minute tolerance.
  • Computes HMAC over the exact raw bytes.
  • Uses timingSafeEqual, not ordinary string equality.
  • Supports overlapping secrets during rotation.
// lib/devup-webhook-signature.ts
import { createHmac, timingSafeEqual } from "node:crypto";

type ParsedHeader = {
  timestamp: number;
  signatures: string[];
};

function parseSignatureHeader(header: string): ParsedHeader | null {
  let timestamp: number | null = null;
  const signatures: string[] = [];

  for (const element of header.split(",")) {
    const [key, value] = element.trim().split("=", 2);

    if (key === "t" && /^\d+$/.test(value ?? "")) {
      if (timestamp !== null) return null;
      timestamp = Number(value);
    }

    if (key === "v1" && /^[0-9a-f]{64}$/i.test(value ?? "")) {
      signatures.push(value.toLowerCase());
    }
  }

  if (
    timestamp === null ||
    !Number.isSafeInteger(timestamp) ||
    signatures.length === 0
  ) {
    return null;
  }

  return { timestamp, signatures };
}

export function verifyDevupWebhookSignature(options: {
  rawBody: Buffer;
  signatureHeader: string;
  secrets: string[];
  toleranceSeconds?: number;
  nowSeconds?: number;
}): boolean {
  const {
    rawBody,
    signatureHeader,
    secrets,
    toleranceSeconds = 300,
    nowSeconds = Math.floor(Date.now() / 1000),
  } = options;

  const parsed = parseSignatureHeader(signatureHeader);
  if (!parsed || secrets.length === 0) return false;

  if (Math.abs(nowSeconds - parsed.timestamp) > toleranceSeconds) {
    return false;
  }

  const signedPayload = Buffer.concat([
    Buffer.from(`${parsed.timestamp}.`, "utf8"),
    rawBody,
  ]);

  for (const secret of secrets) {
    if (!secret) continue;

    const expected = createHmac("sha256", secret)
      .update(signedPayload)
      .digest();

    for (const candidateHex of parsed.signatures) {
      const candidate = Buffer.from(candidateHex, "hex");

      if (
        candidate.length === expected.length &&
        timingSafeEqual(candidate, expected)
      ) {
        return true;
      }
    }
  }

  return false;
}
Enter fullscreen mode Exit fullscreen mode

Timestamp validation limits the replay window. It does not provide complete replay protection on its own: the delivery ID must also be persisted with a uniqueness constraint.

Step 3: Create a local job before submitting inference

The job-creation route generates 32 random bytes for the callback token, stores only its hash, and constructs the callback URL itself. It does not accept a webhook URL from the browser.

// app/api/jobs/route.ts
import { createHash, randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { sql } from "@/lib/db";

export const runtime = "nodejs";

const inputSchema = z.object({
  prompt: z.string().trim().min(1).max(4_000),
  maxNewTokens: z.number().int().min(1).max(2_000).default(300),
});

const queuedSchema = z.object({
  id: z.string().min(1),
  inference_status: z.object({
    status: z.literal("queued"),
  }),
}).passthrough();

function sha256(value: string): string {
  return createHash("sha256").update(value, "utf8").digest("hex");
}

export async function POST(request: Request) {
  // Protect this route with your application's existing authentication and
  // authorization before exposing it to end users.
  let requestBody: unknown;
  try {
    requestBody = await request.json();
  } catch {
    return NextResponse.json({ error: "invalid_json" }, { status: 400 });
  }

  const parsedInput = inputSchema.safeParse(requestBody);
  if (!parsedInput.success) {
    return NextResponse.json({ error: "invalid_request" }, { status: 400 });
  }

  const apiKey = process.env.DEVUP_API_KEY;
  const model = process.env.DEVUP_MODEL_ID;
  const appBaseUrl = process.env.APP_BASE_URL;

  if (!apiKey || !model || !appBaseUrl) {
    return NextResponse.json({ error: "server_misconfigured" }, { status: 500 });
  }

  const callbackToken = randomBytes(32).toString("base64url");
  const callbackTokenHash = sha256(callbackToken);

  const [job] = await sql<{ id: string }[]>`
    INSERT INTO ai_jobs (callback_token_hash, status)
    VALUES (${callbackTokenHash}, 'submitting')
    RETURNING id
  `;

  const callbackUrl = new URL(
    `/api/webhooks/devupai/${callbackToken}`,
    appBaseUrl,
  ).toString();

  const modelPath = model
    .split("/")
    .map((segment) => encodeURIComponent(segment))
    .join("/");

  let response: Response;

  try {
    response = await fetch(
      `https://api.devupai.com/v1/inference/${modelPath}`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          input: parsedInput.data.prompt,
          parameters: {
            max_new_tokens: parsedInput.data.maxNewTokens,
            temperature: 0.2,
          },
          webhook: callbackUrl,
        }),
        signal: AbortSignal.timeout(15_000),
        cache: "no-store",
      },
    );
  } catch {
    // The connection failed, but the remote service may still have accepted
    // the request. Do not resubmit automatically without an idempotency
    // contract; mark the outcome for reconciliation instead.
    await sql`
      UPDATE ai_jobs
      SET status = 'submission_unknown', updated_at = now()
      WHERE id = ${job.id}
    `;

    return NextResponse.json(
      { jobId: job.id, status: "submission_unknown" },
      { status: 202 },
    );
  }

  if (!response.ok) {
    const uncertain = response.status >= 500;

    await sql`
      UPDATE ai_jobs
      SET
        status = ${uncertain ? "submission_unknown" : "failed"},
        error = ${JSON.stringify({ type: "submission_rejected" })}::jsonb,
        updated_at = now()
      WHERE id = ${job.id}
    `;

    return NextResponse.json(
      { jobId: job.id, status: uncertain ? "submission_unknown" : "failed" },
      { status: uncertain ? 202 : 502 },
    );
  }

  let responseBody: unknown;
  try {
    responseBody = await response.json();
  } catch {
    responseBody = null;
  }

  const queued = queuedSchema.safeParse(responseBody);
  if (!queued.success) {
    await sql`
      UPDATE ai_jobs
      SET status = 'submission_unknown', updated_at = now()
      WHERE id = ${job.id}
    `;

    return NextResponse.json(
      { jobId: job.id, status: "submission_unknown" },
      { status: 202 },
    );
  }

  await sql`
    UPDATE ai_jobs
    SET
      remote_inference_id = ${queued.data.id},
      status = CASE
        WHEN status = 'submitting' THEN 'queued'
        ELSE status
      END,
      updated_at = now()
    WHERE id = ${job.id}
  `;

  return NextResponse.json(
    { jobId: job.id, status: "queued" },
    { status: 202 },
  );
}
Enter fullscreen mode Exit fullscreen mode

Why submission_unknown exists

A client timeout is not proof that the server rejected a request. The remote system may have accepted it before the connection failed. Automatically retrying an ambiguous submission can create a duplicate job.

Treat submission_unknown as an operational state requiring reconciliation. This is more honest than changing it to failed and more controlled than blind retries.

Step 4: Receive, authenticate, validate, and deduplicate

Create the dynamic callback route:

// app/api/webhooks/devupai/[token]/route.ts
import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { sql } from "@/lib/db";
import { verifyDevupWebhookSignature } from "@/lib/devup-webhook-signature";

export const runtime = "nodejs";

const succeededSchema = z.object({
  id: z.string().min(1),
  status: z.literal("succeeded"),
  results: z.array(z.unknown()).min(1),
}).passthrough();

const failedSchema = z.object({
  id: z.string().min(1),
  status: z.literal("failed"),
  error: z.object({
    type: z.string().min(1),
    message: z.string().min(1),
  }).passthrough(),
}).passthrough();

const deliverySchema = z.discriminatedUnion("status", [
  succeededSchema,
  failedSchema,
]);

function sha256(value: string): string {
  return createHash("sha256").update(value, "utf8").digest("hex");
}

export async function POST(
  request: Request,
  context: { params: Promise<{ token: string }> },
) {
  const { token } = await context.params;

  if (!/^[A-Za-z0-9_-]{43}$/.test(token)) {
    return NextResponse.json({ error: "not_found" }, { status: 404 });
  }

  const signatureHeader = request.headers.get("x-devup-signature");
  const deliveryId = request.headers.get("x-devup-delivery-id");
  const secrets = (process.env.DEVUP_WEBHOOK_SECRETS ?? "")
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean);

  if (
    !signatureHeader ||
    signatureHeader.length > 2_048 ||
    !deliveryId ||
    deliveryId.length > 200 ||
    secrets.length === 0
  ) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }

  const rawBody = Buffer.from(await request.arrayBuffer());

  const validSignature = verifyDevupWebhookSignature({
    rawBody,
    signatureHeader,
    secrets,
  });

  if (!validSignature) {
    return NextResponse.json({ error: "invalid_signature" }, { status: 401 });
  }

  let decoded: unknown;
  try {
    decoded = JSON.parse(rawBody.toString("utf8"));
  } catch {
    return NextResponse.json({ error: "invalid_json" }, { status: 400 });
  }

  const parsedDelivery = deliverySchema.safeParse(decoded);
  if (!parsedDelivery.success) {
    return NextResponse.json({ error: "invalid_payload" }, { status: 400 });
  }

  const tokenHash = sha256(token);

  const outcome = await sql.begin(async (tx) => {
    const [job] = await tx<{ id: string }[]>`
      SELECT id
      FROM ai_jobs
      WHERE callback_token_hash = ${tokenHash}
      FOR UPDATE
    `;

    if (!job) return "unknown_job" as const;

    const inserted = await tx<{ delivery_id: string }[]>`
      INSERT INTO webhook_deliveries (delivery_id, job_id)
      VALUES (${deliveryId}, ${job.id})
      ON CONFLICT (delivery_id) DO NOTHING
      RETURNING delivery_id
    `;

    if (inserted.length === 0) return "duplicate" as const;

    const payload = parsedDelivery.data;

    if (payload.status === "succeeded") {
      await tx`
        UPDATE ai_jobs
        SET
          status = 'succeeded',
          result = ${JSON.stringify(payload.results)}::jsonb,
          error = NULL,
          updated_at = now()
        WHERE id = ${job.id}
      `;
    } else {
      await tx`
        UPDATE ai_jobs
        SET
          status = 'failed',
          error = ${JSON.stringify(payload.error)}::jsonb,
          updated_at = now()
        WHERE id = ${job.id}
      `;
    }

    return "processed" as const;
  });

  if (outcome === "unknown_job") {
    return NextResponse.json({ error: "not_found" }, { status: 404 });
  }

  // Both a new delivery and a duplicate retry are acknowledged with 2xx.
  return NextResponse.json({ received: true, outcome });
}
Enter fullscreen mode Exit fullscreen mode

The transaction couples two operations:

  1. Reserving the unique delivery ID.
  2. Applying the job state change.

If the database transaction fails, neither operation commits and the route returns 5xx, allowing a retry. If it succeeds, later retries find the same delivery ID and return 200 without changing the job again.

Step 5: Test the cryptographic boundary

The verifier is small enough to test deterministically. The following tests prove three essential properties: the original message succeeds, modified bytes fail, and a stale timestamp fails.

// lib/devup-webhook-signature.test.ts
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import test from "node:test";
import { verifyDevupWebhookSignature } from "./devup-webhook-signature";

const secret = "test-secret";
const now = 1_800_000_000;

function sign(body: Buffer, timestamp: number): string {
  const signature = createHmac("sha256", secret)
    .update(Buffer.concat([
      Buffer.from(`${timestamp}.`, "utf8"),
      body,
    ]))
    .digest("hex");

  return `t=${timestamp},v1=${signature}`;
}

test("accepts the exact signed bytes", () => {
  const body = Buffer.from('{"status":"succeeded"}');

  assert.equal(verifyDevupWebhookSignature({
    rawBody: body,
    signatureHeader: sign(body, now),
    secrets: [secret],
    nowSeconds: now,
  }), true);
});

test("rejects a modified body", () => {
  const signedBody = Buffer.from('{"status":"succeeded"}');
  const modifiedBody = Buffer.from('{"status":"failed"}');

  assert.equal(verifyDevupWebhookSignature({
    rawBody: modifiedBody,
    signatureHeader: sign(signedBody, now),
    secrets: [secret],
    nowSeconds: now,
  }), false);
});

test("rejects a stale delivery", () => {
  const body = Buffer.from('{"status":"succeeded"}');

  assert.equal(verifyDevupWebhookSignature({
    rawBody: body,
    signatureHeader: sign(body, now - 301),
    secrets: [secret],
    nowSeconds: now,
    toleranceSeconds: 300,
  }), false);
});
Enter fullscreen mode Exit fullscreen mode

Run the test using your project's TypeScript test setup. Also add integration tests covering:

  • Missing signature header → 401.
  • Wrong secret → 401.
  • Malformed JSON with a valid signature → 400.
  • Unknown callback token with a valid signature → 404.
  • First valid delivery → job updated once.
  • Same delivery ID again → 200, no second update.
  • Database outage → 500, no delivery record committed.
  • Both current and previous signing secrets during rotation.

HTTP status codes are part of the retry design

Situation Response Reason
Valid new delivery persisted 200 Delivery completed.
Valid duplicate delivery 200 Already processed; stop retrying.
Missing or invalid signature 401 Permanent authentication failure.
Unknown callback token 404 Permanent routing failure.
Valid signature but malformed payload 400 Permanent integration error.
Temporary database failure 500 Safe to retry because the transaction did not commit.

Do not return 200 before the event is durably stored. Conversely, do not return 500 after the state change has committed unless idempotency is already guaranteed.

Security properties—and their limits

What the signature proves

When correctly verified, the HMAC proves that the raw body was signed by a party holding the shared signing secret and that the signed bytes were not modified.

What it does not prove

It does not prove that the generated content is correct, safe, or appropriate for an irreversible action. Treat model output as untrusted data.

Why the callback token still matters

The signature authenticates the body, not your local job mapping. A random, unguessable token in the server-generated callback path provides a robust correlation mechanism without assuming the callback contains your internal job ID.

Why the API key is unrelated

The DEVUP AI API key authorizes outbound inference requests. The webhook signing secret authenticates inbound callbacks. They are separate credentials with separate purposes. Do not reuse one as the other.

Production hardening checklist

  • [ ] Authenticate and authorize the job-creation route.
  • [ ] Associate every job with its owner and enforce ownership when reading results.
  • [ ] Keep the callback URL server-generated and HTTPS-only.
  • [ ] Store only a hash of the random callback token.
  • [ ] Verify the signature against the raw body before parsing JSON.
  • [ ] Enforce a timestamp tolerance.
  • [ ] Deduplicate with X-DevUp-Delivery-Id, not the signature.
  • [ ] Process the delivery inside a database transaction.
  • [ ] Return 2xx quickly after durable persistence.
  • [ ] Queue email, notification, or file-processing side effects separately.
  • [ ] Never log API keys, signing secrets, callback tokens, raw prompts, or full results.
  • [ ] Cap max_new_tokens and validate all user input.
  • [ ] Rotate webhook secrets and test the overlap window.
  • [ ] Alert on repeated signature failures and sustained delivery errors.
  • [ ] Define retention and deletion rules for stored results.

Common mistakes

Parsing before verification

// Wrong: the original bytes have already been lost.
const payload = await request.json();
const reconstructed = JSON.stringify(payload);
Enter fullscreen mode Exit fullscreen mode

Read request.arrayBuffer() first, verify the resulting bytes, and only then parse them.

Deduplicating on the signature

The signature timestamp may be recomputed for a retry. Use the stable delivery ID instead.

Performing slow work inside the webhook

Do not send email, transform large files, or run another model call before responding. Persist the event, enqueue follow-up work, and acknowledge quickly.

Retrying an ambiguous submission blindly

A timeout can occur after the remote service accepted the request. Preserve an explicit unknown state and reconcile it instead of creating an uncontrolled duplicate.

Exposing a user-controlled callback URL

The browser should not choose where server-side callbacks are sent. Generate callback URLs from a trusted application base URL.

Final result

We have converted a potentially fragile long-running HTTP request into a durable state machine:

submitting ──► queued ──► succeeded
     │            └────► failed
     └─────────────────► submission_unknown
Enter fullscreen mode Exit fullscreen mode

The important part is not merely receiving a callback. It is making the callback safe to retry, impossible to trust before authentication, and atomic with the state change it represents.

That requires five invariants:

  1. The API key remains server-side.
  2. The signature is verified over the exact raw bytes.
  3. Old timestamps are rejected.
  4. Delivery IDs are unique in durable storage.
  5. Job updates and delivery reservations commit together.

With those properties in place, DEVUP AI webhooks can support background generation, batch processing, scheduled workflows, and other jobs that should not depend on one long-lived client connection.

Start with the public DEVUP AI Webhooks documentation, create a dedicated signing secret, and test the failure paths before sending production traffic.

What long-running AI workflow would you move away from a synchronous request first?

Top comments (0)