Appearance
Track completion with webhooks
Your CRM should know when a contract is signed, without polling. This guide builds a receiver that marks a deal signed when its request completes.
1. Link your records to JustDocs
When you create a signing request, store its id with your record:
js
const request = await createSigningRequest(deal); // POST /api/v1/signing-requests
await db.deals.update(deal.id, { justdocs_request_id: request.id });2. Add the endpoint
On the team's Webhooks page, add your URL, such as https://crm.example.com/webhooks/justdocs. Choose request.sent, request.signed, request.completed, request.declined, request.voided and request.expired. Store the secret as JUSTDOCS_WEBHOOK_SECRET.
3. Receive, verify, queue
js
import crypto from "node:crypto";
import express from "express";
const app = express();
function verify(header, raw) {
const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("=", 2)));
if (!parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", process.env.JUSTDOCS_WEBHOOK_SECRET).update(`${parts.t}.${raw}`).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
app.post("/webhooks/justdocs", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(req.get("JustDocs-Signature"), raw)) return res.sendStatus(400);
const event = JSON.parse(raw);
try {
await queue.add("justdocs-event", event);
} catch {
// Not queued: answering with an error makes JustDocs deliver it again.
return res.sendStatus(500);
}
res.sendStatus(204);
});Answer within a few seconds, and only once the event is queued. The work happens on the queue.
Don't drop duplicates here. If you record an event as seen and then fail to queue it, its retry looks like a duplicate and the update is lost. Step 4 drops them when the work is done.
4. Act on each event once
Events say what happened, not every detail. Fetch the request for its current state. That also handles events arriving out of order.
js
queue.process("justdocs-event", async (event) => {
if (!event.type.startsWith("request.")) return;
const deal = await db.deals.findBy({ justdocs_request_id: event.data.signing_request_id });
if (!deal) return;
const response = await fetch(`https://tryjustdocs.com/api/v1/signing-requests/${event.data.signing_request_id}`, {
headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}` },
});
if (!response.ok) throw new Error(`JustDocs answered ${response.status}`); // the queue tries the job again
const request = await response.json();
await db.transaction(async (tx) => {
// Each event id is applied once, however many times it arrives. The id is
// recorded with the update it caused: if the update fails, neither is kept,
// and the next attempt applies it.
const fresh = await tx.webhookEvents.insertIfAbsent({ id: event.id, type: event.type, processed_at: new Date() });
if (!fresh) return;
await tx.deals.update(deal.id, {
contract_status: request.status, // draft, sent, completed, declined, voided, expired
signed_by: request.recipients.filter((recipient) => recipient.status === "signed").map((recipient) => recipient.email),
contract_completed_at: request.completed_at,
});
});
});Writing the request's current state is safe to repeat. The id check is what keeps anything else an event sets off, such as a task for your team, to once.
5. Test it
- Choose Send a test on the endpoint. Your receiver should answer
204to theping, and ignore it. - Send yourself a request from the app and sign it. Watch
request.sent,request.viewed,request.signedandrequest.completedarrive. - Check Deliveries on the endpoint for status codes and errors.
If your receiver goes down
Failed deliveries are retried for about 20 hours. If the endpoint keeps failing, it's paused and you're emailed. After you resume it, catch up by listing signing requests and comparing their statuses with your records.