Webhooks
How to receive, verify and deduplicate done. events, how retries work, and how to catch up on what you missed.
This page shows how to receive webhooks from done., check that each delivery is genuine, and recover when your receiver was down.
A webhook is a request done. makes to your server when something happens: a person replied, a scoring run finished, a draft needs your decision. It saves you from polling.
Add an endpoint
Add an endpoint with POST /webhooks. It needs a key with full permission.
curl https://api.done.com/v1/webhooks \
-H "Authorization: Bearer $DONE_API_KEY" \
-H "Idempotency-Key: c2a94e7b-31d8-4f6a-b0e5-7d1a3f8e2c64" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/done",
"events": ["person.replied", "person.meeting_booked"]
}'urlmust be https and reachable from the public internet. Addresses on private networks, and names that point to them, are refused withurl_not_allowed.eventslists the event types the endpoint receives. Send["*"], which is the default, to receive every event, including events added later.- The answer includes the signing secret,
whsec_..., once. Store it now. If you lose it, make a new one withPOST /webhooks/{id}/rotate_secret. - The number of endpoints a workspace may have depends on its plan. Past it, the call is refused with
webhook_limit_reached.
What a delivery looks like
Each delivery is a POST to your endpoint with User-Agent: done-webhooks/1.0. The body is the event, as JSON, with lowercase field names:
{
"id": "evt_1DxBm6Hk",
"object": "event",
"type": "scoring_run.completed",
"created_at": "2026-10-23T07:58:00Z",
"workspace": "ws_4fQk2LmZ",
"data": {
"id": "run_8VcXz3Qa",
"object": "scoring_run",
"name": "UK finance leaders, October",
"status": "done",
"customer_profile": "cp_3WnYt9Bd",
"people": { "sent": 212, "to_score": 212, "done": 209, "excluded": 0, "failed": 3 },
"credits": { "held": 0, "spent": 209 },
"finished_at": "2026-10-23T07:57:50Z"
}
}Four headers come with it:
| Header | What it holds |
|---|---|
Done-Signature | The signature, as t=<unix seconds>,v1=<hex>. |
Done-Event-Type | The event type, such as person.replied. |
Done-Event-Id | The event's id. Use it to deduplicate. |
Done-Delivery-Id | The id of this delivery. |
Verify the signature
Always check Done-Signature before you trust a body. Anyone can send a request to your URL. Only done. can sign one.
The signature is the HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint's whole secret, the whsec_... string as text (not decoded), written in hex. t is the number from the header, and <raw body> is the exact bytes you received.
To verify a delivery:
- Read the raw body before you parse it. Parsing the JSON and writing it out again changes the bytes and breaks the signature.
- Split the header into
tandv1. - Reject the delivery if
tis more than 5 minutes old. - Compute the HMAC-SHA256 of
t, a dot, and the raw body with your secret. - Compare the result with
v1in constant time. - Only then parse the JSON.
Here is a complete receiver in Node.js, with no framework:
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.DONE_WEBHOOK_SECRET; // the whole whsec_... string, as text
if (!SECRET) throw new Error('Set DONE_WEBHOOK_SECRET to the endpoint secret.');
const MAX_AGE_SECONDS = 5 * 60;
function isGenuine(rawBody, header) {
const parts = Object.fromEntries(
header.split(',').map((part) => {
const at = part.indexOf('=');
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
// Reject a timestamp older than 5 minutes (and one far in the future).
if (Math.abs(Date.now() / 1000 - t) > MAX_AGE_SECONDS) return false;
const expected = createHmac('sha256', SECRET).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1, 'hex');
// timingSafeEqual needs equal lengths, so check the length first.
return given.length === expected.length && timingSafeEqual(given, expected);
}
const seen = new Set(); // use a database or Redis in production
createServer(async (req, res) => {
if (req.method !== 'POST') return res.writeHead(405).end();
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks);
if (!isGenuine(rawBody, req.headers['done-signature'] ?? '')) {
return res.writeHead(400).end();
}
// The same event can arrive twice. Deduplicate on Done-Event-Id.
const eventId = req.headers['done-event-id'];
if (seen.has(eventId)) return res.writeHead(200).end();
seen.add(eventId);
const event = JSON.parse(rawBody.toString('utf8'));
res.writeHead(200).end(); // answer first, then do the slow work
console.log(event.type, event.id);
}).listen(3000);Test your code with POST /webhooks/{id}/test. It sends a webhook.test event, signed exactly like a real one.
Answer fast, and deduplicate
- Answer with any 2xx within 10 seconds. Do slow work after you have answered, for example by putting the event on a queue.
- The same event can arrive twice. Deduplicate on the
Done-Event-Idheader. Store the ids you have handled.
Retries
If your endpoint answers with anything other than a 2xx, or does not answer in time, the delivery is retried with growing gaps for up to 24 hours.
After 20 failed deliveries in a row, the endpoint is switched off. Its status becomes disabled and disabled_because is too_many_failures. Fix your receiver, then set status to active with PATCH /webhooks/{id}.
To see how deliveries went, call GET /webhooks/{id}/deliveries. To send one again, call POST /webhooks/{id}/deliveries/{delivery_id}/resend.
Catch up with GET /events
If your receiver was down, or you prefer to poll, GET /events lists everything that happened in the workspace, newest first, in the same envelope webhooks use.
curl "https://api.done.com/v1/events?since=2026-10-23T06:00:00Z&type=person.replied" \
-H "Authorization: Bearer $DONE_API_KEY"Remember the newest created_at you handled and pass it as since next time. Deduplicate on the event id. See Lists and pages to read more than one page.
Rotate the secret
POST /webhooks/{id}/rotate_secret makes a new signing secret and returns it once. Deliveries are signed with the new secret at once, and the old secret stops working at the same moment. Update your receiver first, or expect a short run of failures. Only an admin's key may rotate a secret.
Versions
Each endpoint is pinned to the API version of the day it was made, and receives event names and shapes from that version. See Versions.
Events
| Event | When it is sent |
|---|---|
person.replied | A person writes to you. Sent once for each incoming message. Replies to people who raised their hand are never answered automatically, so this event is how you hear about them. |
person.connected | A person accepts a connection invitation. |
person.meeting_booked | A conversation is marked as a booked meeting. |
person.scored | A person's fit against a customer profile has been checked. data is a score. |
message.sent | A message has gone out to a person, from a campaign step or as a reply. |
invite.sent | A connection invitation has gone out to a person. |
scoring_run.completed | A scoring run ends. Fetch the results with GET /scoring_runs/{id}/results. |
campaign.launched | A campaign is launched. Sending starts later, on the campaign's schedule. |
campaign.paused | A campaign is paused, by you or by done. data.reason is you, account_disconnected or account_restricted. |
campaign.resumed | A paused campaign becomes active again. |
campaign.completed | A campaign becomes completed. |
campaign.person_finished | A person's journey in a campaign ends. data.outcome is sequence_finished, replied, meeting_booked or stopped. |
scout.run_finished | A scout finishes one run. Read the people with GET /scouts/{id}/people. |
scout.stopped | done. stops a scout. data.stopped_because says why. |
scout.first_look_ready | A scout that checks fit has made its first pass and waits for your decision. |
draft.waiting | A draft starts waiting for approval. |
goal.proposed | A goal's plan is ready. Nothing has been created or sent yet. |
webhook.test | You call POST /webhooks/{id}/test. |
The API reference shows the full body of each event.