Server
A managed poller and one unified, signed webhook for batch completion — using native provider webhooks where they exist.
Instead of polling yourself, batchwork/server watches open batches and delivers one signed webhook to your endpoint when each finishes — using OpenAI's native webhooks where available and a managed poller everywhere else.
Track and tick
import { batch } from "batchwork";
import { createBatchPoller, createMemoryStore } from "batchwork/server";
const store = createMemoryStore(); // or createPostgresStore / createRedisStore
const poller = createBatchPoller({ store }); // credentials default to env vars
// 1. When you submit, register the batch and where to notify.
const job = await batch({ model, requests });
await poller.track(job, {
webhookUrl: "https://acme.com/webhooks/batch",
secret: process.env.BATCH_WEBHOOK_SECRET,
});
// 2. Run tick() on a schedule (Vercel Cron, a worker, setInterval). It polls
// open batches and POSTs a signed event for each one that just finished.
export const GET = async () => {
const { delivered } = await poller.tick();
return Response.json({ delivered });
};Default webhook delivery only accepts HTTPS destinations and rejects localhost or private-network URL literals. If your app accepts user-provided webhook URLs, validate them against your own tenant allowlist before calling track; for custom network policy, pass validateWebhookUrl to createBatchPoller.
Receive the webhook
Your endpoint receives a unified, signed BatchWebhookEvent:
import { getBatchResults } from "batchwork";
import { verifyBatchWebhook } from "batchwork/server";
export const POST = async (request: Request) => {
const event = await verifyBatchWebhook(
request,
process.env.BATCH_WEBHOOK_SECRET
);
// event: { type, id, provider, requestCounts }
for await (const r of getBatchResults({
provider: event.provider,
id: event.id,
})) {
// …persist each result
}
return new Response("ok");
};The type is one of batch.completed, batch.failed, batch.expired, or batch.cancelled.
Skip polling for OpenAI
OpenAI emits native webhooks. Mount the handler to deliver the instant a batch completes — no tick() needed for OpenAI batches:
const handler = poller.openaiWebhookHandler({
signingSecret: process.env.OPENAI_WEBHOOK_SECRET,
});
export const POST = (request: Request) => handler(request); // your OpenAI webhook routeDurable stores
createMemoryStore() is for development. For production, batchwork ships two durable adapters — pass either as the poller's store.
Postgres — batchwork/postgres
import { Pool } from "pg";
import { createBatchPoller } from "batchwork/server";
import { createPostgresStore, migratePostgres } from "batchwork/postgres";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
await migratePostgres(db); // creates the tables (idempotent); run once at boot
const poller = createBatchPoller({
store: createPostgresStore({ client: db }),
});client is anything with a node-postgres-style query(text, params) — pg.Pool, @neondatabase/serverless, etc. — so batchwork adds no driver dependency of its own.
Redis (Upstash) — batchwork/redis
import { Redis } from "@upstash/redis";
import { createRedisStore } from "batchwork/redis";
const store = createRedisStore({ redis: Redis.fromEnv() });Works on edge runtimes (Upstash speaks HTTP). Pass a prefix to namespace keys when one Redis backs multiple apps.
Custom
Both adapters are thin implementations of the BatchStore interface — implement it yourself over any other KV/DB (Cloudflare KV, DynamoDB, …):
import type { BatchStore } from "batchwork/server";
const store: BatchStore = {
get: (id) => kv.get(id),
set: (record) => kv.set(record.id, record),
delete: (id) => kv.del(id),
list: ({ delivered } = {}) => kv.query({ delivered }),
};Signing
Signing is Standard Webhooks-compatible (webhook-id / webhook-timestamp / webhook-signature, HMAC-SHA256 over id.timestamp.body), so existing webhook tooling verifies it. It uses Web Crypto, so it runs on edge runtimes. signWebhook, verifyWebhook, and verifyBatchWebhook are all exported.
verifyWebhook and verifyBatchWebhook reject replayed webhook-id values
inside the timestamp tolerance window. The default replay cache is in-process;
pass a durable replayStore option if your receiver runs across multiple
instances or regions.