Automate file workflows with APULODI webhooks in a Next.js app
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:
| Event | Fires when |
|---|---|
file.created | an upload is initiated and the record is pending |
file.uploaded | bytes are verified in storage and the file is uploaded |
file.downloaded | a download URL is issued |
file.replaced | a replacement publishes a new version |
file.copied / file.renamed / file.moved | the corresponding file operation succeeds |
file.metadata_updated | custom metadata is replaced |
file.deleted / file.restored / file.purged | soft-delete, restore, and the end of the purge window |
upload.initiated / upload.completed / upload.aborted | multipart 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:
- Duplicates happen. Network timeouts, deployments, a 500 you didn't mean
to return — any of these can produce a redelivery of an event you already
handled. Your receiver must deduplicate on
event.id. - Retries back off: 1s, 5s, 30s, 2m, then the delivery is marked
FAILEDin the dashboard. You can redeliver any delivery manually from there. - Ordering is not guaranteed. Under load or retry,
file.deletedcan arrive afterfile.createdfor the same file even though the delete happened first. Never infer state from arrival order; trust the payload. - A non-2xx response is a failure and triggers the retry schedule. So does letting the request time out.
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 → Webhooks → Register 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:
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:
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:
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:
// 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:
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
- Parsing before verifying.
await request.json()then verifying the re-serialized string fails intermittently and you cannot reproduce it. The signature covers exact bytes; verifyrequest.text(). - 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. - 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.
- Assuming ordered delivery. If
file.deletedarrives before thefile.createdyou're still processing, correct handling comes from the payload, not the sequence. - 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
- Marketplace product photos. On
file.uploadedinproducts/, enqueue a job that generates six sizes and a blur placeholder. Sellers see images "just work"; nobody uploads a 12 MB JPEG to your product page again. - KYC document intake. On
file.uploadedinkyc/, run a malware scan and write the verdict into the file's metadata. Compliance gets an auditable trail for free — every event, with its ID, is a record. - Search indexing. On
file.renamedorfile.moved, update the document in your search index. Your users' search results never go stale. - Long-upload UX.
upload.initiated→ show "uploading…";upload.completed→ send "your video is ready". Multipart sessions can take minutes; the events track them precisely. - Cache invalidation.
file.replacedmeans a new version exists — purge the old asset from your CDN and bump the cache key. - Cost hygiene.
file.deletedis your cue to delete derivatives in your own systems, so your thumbnail bucket doesn't outlive the originals.
Testing the receiver locally
Webhooks need a public URL, so for local development put a tunnel in front of your app:
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:
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
- Signature verified over the raw body, every delivery, no exceptions
- Replay window enforced on the
ttimestamp - Deduplication on
event.idbacked by a durable store, not memory - Acknowledge in under a second; slow work goes to a queue
- Event filter configured at registration
-
FAILEDdeliveries monitored; dashboard redelivery used for recovery - Endpoint secret stored in your secret manager — shown once, remember
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.