Receive CSV imports through a signed webhook
Verify CSVJetty webhook signatures, validate imported contacts, and commit records with durable batch deduplication in PostgreSQL.
View as MarkdownChoose the receiving boundary
Use webhook delivery when your backend should receive imported records directly from CSVJetty. This tutorial builds a dedicated Next.js Pages Router endpoint for one trusted importer and one fixed tenant. The webhook secret authenticates the sender; the server-configured tenant ID decides where records belong.
A public importer key is not user authentication. Keep this example on an access-controlled import workflow for trusted operators. A shared public importer does not establish end-user identity or authorize writes to arbitrary customer accounts. For multi-tenant self-service imports, use authenticated callback delivery unless your own server can securely bind each import to its tenant.
1. Configure webhook delivery
npm install csvjetty pg
In the CSVJetty dashboard, choose Webhook delivery and set your deployed HTTPS endpoint to https://YOUR_APP/api/csvjetty-webhook. Configure name (required text), email (required email), and company (optional text). Enable Require valid rows. Copy the importer’s webhook secret to your server environment.
# .env.local and your deployment environment
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DATABASE
CSVJETTY_WEBHOOK_SECRET=YOUR_IMPORTER_WEBHOOK_SECRET
CSVJETTY_TENANT_ID=YOUR_FIXED_INTERNAL_TENANT_ID
The endpoint must be publicly reachable over HTTPS. localhost is not a delivery target. Configure PostgreSQL TLS according to your database provider; do not disable certificate verification.
// pages/import-contacts.jsx
import CSVJetty from "csvjetty";
export default function ImportContacts() {
return (
<main>
<h1>Import contacts</h1>
<CSVJetty importerKey="YOUR_IMPORTER_KEY">Import contacts</CSVJetty>
</main>
);
}

2. Create the storage tables
The receipt and contact writes commit in one transaction. If the process stops before commit, neither is stored. If the HTTP response is lost after commit, the next delivery returns the stored result. Keep receipts for at least the delivery retry period; this example retains them indefinitely.
create table csvjetty_webhook_receipts (
tenant_id text not null,
import_id text not null,
batch integer not null,
payload_hash text not null,
result jsonb not null,
primary key (tenant_id, import_id, batch)
);
create table csvjetty_webhook_contacts (
id bigint generated always as identity primary key,
tenant_id text not null,
import_id text not null,
source_row integer not null,
name varchar(200) not null,
email varchar(254) not null,
company varchar(200) not null,
unique (tenant_id, import_id, source_row)
);
-- Use a backend-only database role. Do not expose these tables via a public API.
// lib/import-pg.js
import pg from "pg";
const globalForPg = globalThis;
export const importPool = globalForPg.importPool || new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
connectionTimeoutMillis: 3000,
statement_timeout: 5000,
});
if (process.env.NODE_ENV !== "production") globalForPg.importPool = importPool;
3. Validate mapped records
// lib/validate-contact-import.js
export function validateContactImport(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new Error("Expected an import object.");
}
const { importId, rows, rowNumbers } = body;
if (typeof importId !== "string" || !importId.length || importId.length > 200 ||
!Array.isArray(rows) || rows.length < 1 || rows.length > 1000 ||
!Array.isArray(rowNumbers) || rows.length !== rowNumbers.length ||
rowNumbers.some(n => !Number.isSafeInteger(n) || n < 1) ||
new Set(rowNumbers).size !== rowNumbers.length) {
throw new Error("Invalid import ID, batch size, or source row numbers.");
}
const accepted = [];
const errors = [];
rows.forEach((row, index) => {
const name = typeof row?.name === "string" ? row.name.trim() : "";
const email = typeof row?.email === "string" ? row.email.trim().toLowerCase() : "";
const company = row?.company == null ? "" :
typeof row.company === "string" ? row.company.trim() : null;
if (!name || name.length > 200 || email.length > 254 ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ||
company === null || company.length > 200) {
errors.push({ row: rowNumbers[index], message: "Enter a name, valid email, and company up to 200 characters." });
} else {
accepted.push({ name, email, company, sourceRow: rowNumbers[index] });
}
});
return { importId, accepted, errors };
}
4. Verify the raw body and save a batch
CSVJetty signs timestamp + period + raw body with HMAC-SHA256. The receiver limits request size, checks the five-minute timestamp window, and compares signatures in constant time before parsing JSON. Repeated batch identity with different signed content returns 409.
// pages/api/csvjetty-webhook.js
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { importPool } from "../../lib/import-pg";
import { validateContactImport } from "../../lib/validate-contact-import";
export const config = { api: { bodyParser: false } };
const MAX_BYTES = 2 * 1024 * 1024;
function validSignature(raw, header, secret) {
if (!secret || typeof header !== "string") return false;
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header);
if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${match[1]}.`).update(raw).digest();
const received = Buffer.from(match[2], "hex");
return received.length === expected.length && timingSafeEqual(expected, received);
}
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({ error: "Use POST." });
}
const secret = process.env.CSVJETTY_WEBHOOK_SECRET;
const tenantId = process.env.CSVJETTY_TENANT_ID;
if (!secret || !tenantId) return res.status(500).json({ error: "Receiver is not configured." });
let raw;
try {
const chunks = [];
let bytes = 0;
for await (const chunk of req) {
const buffer = Buffer.from(chunk);
bytes += buffer.length;
if (bytes > MAX_BYTES) return res.status(413).json({ error: "Request too large." });
chunks.push(buffer);
}
raw = Buffer.concat(chunks);
} catch { return res.status(400).json({ error: "Could not read request." }); }
if (!validSignature(raw, req.headers["csvjetty-signature"], secret)) {
return res.status(401).json({ error: "Invalid signature." });
}
let payload, validated;
try {
payload = JSON.parse(raw.toString("utf8"));
validated = validateContactImport(payload);
if (!Number.isSafeInteger(payload.batch) || payload.batch < 0 ||
!Number.isSafeInteger(payload.totalBatches) || payload.totalBatches < 1 ||
payload.batch >= payload.totalBatches ||
req.headers["idempotency-key"] !== `${payload.importId}:${payload.batch}`) {
throw new Error("Invalid batch identity.");
}
} catch { return res.status(400).json({ error: "Invalid import payload." }); }
const hash = createHash("sha256").update(raw).digest("hex");
const result = { errors: validated.errors };
let db;
try {
db = await importPool.connect();
await db.query("BEGIN");
const receipt = await db.query(
`insert into csvjetty_webhook_receipts
(tenant_id, import_id, batch, payload_hash, result)
values ($1,$2,$3,$4,$5::jsonb)
on conflict do nothing returning import_id`,
[tenantId, payload.importId, payload.batch, hash, JSON.stringify(result)],
);
if (!receipt.rowCount) {
const { rows: [old] } = await db.query(
`select payload_hash, result from csvjetty_webhook_receipts
where tenant_id=$1 and import_id=$2 and batch=$3`,
[tenantId, payload.importId, payload.batch],
);
await db.query("ROLLBACK");
if (!old || old.payload_hash !== hash) {
return res.status(409).json({ error: "Batch identity was reused with changed data." });
}
return res.status(200).json(old.result);
}
if (validated.accepted.length) {
await db.query(
`insert into csvjetty_webhook_contacts
(tenant_id, import_id, source_row, name, email, company)
select $1,$2,x."sourceRow",x.name,x.email,x.company
from jsonb_to_recordset($3::jsonb)
as x("sourceRow" integer,name text,email text,company text)
on conflict (tenant_id,import_id,source_row) do nothing`,
[tenantId, payload.importId, JSON.stringify(validated.accepted)],
);
}
await db.query("COMMIT");
return res.status(200).json(result);
} catch {
if (db) await db.query("ROLLBACK").catch(() => {});
return res.status(500).json({ error: "Could not save batch. Retry delivery." });
} finally { db?.release(); }
}
5. Exercise success, rejection, and retry
Upload the sample through your configured importer on a test deployment. Verify that contacts and one receipt per delivered batch appear in PostgreSQL. A database failure should produce a failed delivery, not a successful response. Retry failed delivery from the dashboard after restoring the database.
For a controlled retry test, return HTTP 500 immediately after the COMMIT line once in a test environment. The next signed delivery should return the stored result without adding contacts. Remove the test failure afterward. Send a request with a modified body and an old signature; it must return 401.
Returning HTTP 200 with row errors completes delivery for that batch while rejecting the listed rows. Those row errors are stored with the receipt. Use a new import for corrected data after a completed webhook delivery; do not reuse a batch identity with a changed payload.
Import payload and source row numbers →

Troubleshoot persistence and retries
| Symptom | Check | Resolution |
|---|---|---|
| Repeated deliveries insert records twice | The receiver acknowledges or retries without durable deduplication | Record the documented import/delivery identity in a transaction with the write. |
| Signature validation fails | The signed request body was parsed or changed first | Verify the raw body using the documented signature procedure before processing JSON. |
Build your importer
Configure the fields your app expects, then connect CSVJetty to your application.
Create an importer →