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:
- Your backend creates a local job.
- It submits inference with a callback URL.
- DEVUP AI acknowledges the queued request immediately.
- Your application receives a signed webhook when the result is ready.
- 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
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.
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}
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
statusis eithersucceededorfailed.
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
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
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.
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
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
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
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()
);
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,
});
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
v1signature. - 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;
}
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 },
);
}
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 });
}
The transaction couples two operations:
- Reserving the unique delivery ID.
- 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);
});
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
2xxquickly 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_tokensand 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);
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
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:
- The API key remains server-side.
- The signature is verified over the exact raw bytes.
- Old timestamps are rejected.
- Delivery IDs are unique in durable storage.
- 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)