← All posts

Automate file workflows with APULODI webhooks in a Next.js app

September 7, 202612 min readWebhooksNext.jsTutorials

Most file pipelines are cron jobs because events used to be hard to get right. A job wakes up every five minutes, asks "any new files?", mostly hears "no", and your users experience the whole thing as an average delay of two and a half minutes.

Webhooks invert that. When a file lands, when a version is replaced, when a multipart session completes — APULODI POSTs a signed JSON event to your endpoint, within seconds, and your Next.js app reacts immediately. Zero requests when nothing happens. This post is the guide we wish existed when we wired up our own first receiver: the delivery guarantees you must understand before writing code, signature verification done correctly, a complete App Router handler, and the mistakes we see over and over.

The event catalog

Every state change in a project emits an event. Here is the full list today:

EventFires when
file.createdan upload is initiated and the record is pending
file.uploadedbytes are verified in storage and the file is uploaded
file.downloadeda download URL is issued
file.replaceda replacement publishes a new version
file.copied / file.renamed / file.movedthe corresponding file operation succeeds
file.metadata_updatedcustom metadata is replaced
file.deleted / file.restored / file.purgedsoft-delete, restore, and the end of the purge window
upload.initiated / upload.completed / upload.abortedmultipart session lifecycle

Two of these trip people up, so let's be precise. file.created means the record exists and a presigned upload URL was issued — the bytes may not exist yet. file.uploaded means the bytes are verified and present. If you generate thumbnails, subscribe to file.uploaded, not file.created, or you will resize files that were never uploaded.

Delivery guarantees — read this before writing code

APULODI webhooks are at-least-once. That single sentence drives almost everything below:

If you take one thing from this post: your handler should verify, deduplicate, acknowledge, and only then work.

Registering an endpoint

In the dashboard, open your project → WebhooksRegister webhook endpoint. Give it an HTTPS URL, optionally filter which events you want (fewer is better — each filtered event is one your handler never has to ignore), and you'll receive a signing secret prefixed whsec_. The secret is shown exactly once. Store it like a password; if you lose it, delete the endpoint and create a new one.

If you prefer the API, the equivalent is:

bash
curl -X POST https://apulodi.com/v1/webhooks \
  -H "Authorization: Bearer $APULODI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/api/webhooks/apulodi",
    "events": ["file.uploaded", "file.deleted"]
  }'

Verify every delivery — here's how the signature works

Every delivery carries an APULODI-Signature header:

code
APULODI-Signature: t=1725612170,v1=5f8a…

t is a Unix timestamp and v1 is an HMAC-SHA256 of the raw request body keyed with your endpoint secret. Two properties matter: the timestamp lets you reject replays of old deliveries, and the HMAC lets you prove the body was sent by APULODI and not modified in transit. Verification must be done over the raw bytes — if you parse the JSON first and re-serialize it, one escaped slash later the signature no longer matches and you'll chase ghosts.

The SDK does all of it — scheme parsing, timestamp check, constant-time comparison — in one call:

ts
import { verifyWebhookSignature } from "@apulodi/sdk";

const valid = verifyWebhookSignature(secret, rawBody, signatureHeader);
// false → forged, tampered, or replayed. Return 401 and move on.

The receiver: a complete App Router handler

Here is a handler we would actually ship. It verifies the signature, rejects stale events, deduplicates, dispatches, and always acknowledges quickly:

ts
// app/api/webhooks/apulodi/route.ts
import { verifyWebhookSignature } from "@apulodi/sdk";
import { prisma } from "@/lib/db";
import { enqueueThumbnail } from "@/lib/jobs";
import type { NextRequest } from "next/server";

export async function POST(request: NextRequest) {
  // 1. Signature first — over the RAW body, before any parsing.
  const raw = await request.text();
  const signature = request.headers.get("apulodi-signature") ?? "";

  if (!verifyWebhookSignature(process.env.APULODI_WEBHOOK_SECRET!, raw, signature)) {
    return Response.json({ error: "invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(raw) as {
    id: string;
    type: string;
    createdAt: string;
    data: { file: { id: string; path: string } };
  };

  // 2. Reject replays older than 5 minutes.
  const age = Date.now() - new Date(event.createdAt).getTime();
  if (age > 5 * 60_000) {
    return Response.json({ error: "stale event" }, { status: 400 });
  }

  // 3. Deduplicate — at-least-once delivery means this may be a repeat.
  const inserted = await prisma.processedEvent
    .create({ data: { id: event.id } })
    .catch(() => null); // unique constraint → already seen
  if (!inserted) {
    return Response.json({ ok: true });
  }

  // 4. Dispatch. Fast work inline; slow work into your queue.
  switch (event.type) {
    case "file.uploaded":
      if (event.data.file.path.startsWith("products/")) {
        await enqueueThumbnail(event.data.file.id);
      }
      break;
    case "file.deleted":
      await prisma.productImage.updateMany({
        where: { fileId: event.data.file.id },
        data: { deletedAt: new Date() },
      });
      break;
  }

  // 5. Always acknowledge. A 200 tells APULODI to stop retrying.
  return Response.json({ ok: true });
}

The processedEvent table is one column and one unique constraint:

sql
CREATE TABLE "ProcessedEvent" (
  id TEXT PRIMARY KEY,          -- the event id (evt_…)
  "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);

If the insert wins, you own the event. If it conflicts, someone already handled it — acknowledge and move on. For anything heavier than a database update, push a job onto a queue inside the switch and let a worker do the slow part after you've already answered 200.

Five mistakes we see, with their symptoms

  1. Parsing before verifying. await request.json() then verifying the re-serialized string fails intermittently and you cannot reproduce it. The signature covers exact bytes; verify request.text().
  2. Trusting the payload because it came to your URL. Endpoints get discovered, crawled, and fuzzed. Without the signature check, anyone can POST {"type":"file.deleted", …} and delete your data. The signature is the authentication.
  3. Doing slow work before responding. A thumbnail job that takes 40 seconds guarantees APULODI times out and redelivers, multiplying the work. Acknowledge first, work after.
  4. Assuming ordered delivery. If file.deleted arrives before the file.created you're still processing, correct handling comes from the payload, not the sequence.
  5. Subscribing to everything "just in case". Every event you receive is one your handler must dispatch past. Filter at registration; widen later.

Real use cases, concretely

Testing the receiver locally

Webhooks need a public URL, so for local development put a tunnel in front of your app:

bash
ngrok http 3000
# or
cloudflared tunnel --url http://localhost:3000

Register https://<your-tunnel>/api/webhooks/apulodi as the endpoint, then trigger a real event — upload a file through the dashboard or the API. The project's webhook page shows every delivery with its HTTP status and error, and a Redeliver button, which is the difference between debugging and guessing.

One test worth doing by hand: curl your own endpoint with a junk signature and confirm you get a 401:

bash
curl -X POST http://localhost:3000/api/webhooks/apulodi \
  -H "Content-Type: application/json" \
  -H "APULODI-Signature: t=1725612170,v1=deadbeef" \
  -d '{"id":"evt_fake","type":"file.uploaded"}'
# → 401, and your logs stay quiet. Exactly what you want.

A production checklist

Go deeper

The full specification — every event payload, the exact signature scheme, and all endpoint parameters — lives in the webhooks documentation. The SDK's webhooks reference covers verifyWebhookSignature and the delivery-inspection methods. And if you end up building something interesting on top of the events, that is genuinely the best feedback we can get.