done.developers

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"]
  }'
  • url must be https and reachable from the public internet. Addresses on private networks, and names that point to them, are refused with url_not_allowed.
  • events lists 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 with POST /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:

HeaderWhat it holds
Done-SignatureThe signature, as t=<unix seconds>,v1=<hex>.
Done-Event-TypeThe event type, such as person.replied.
Done-Event-IdThe event's id. Use it to deduplicate.
Done-Delivery-IdThe 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:

  1. Read the raw body before you parse it. Parsing the JSON and writing it out again changes the bytes and breaks the signature.
  2. Split the header into t and v1.
  3. Reject the delivery if t is more than 5 minutes old.
  4. Compute the HMAC-SHA256 of t, a dot, and the raw body with your secret.
  5. Compare the result with v1 in constant time.
  6. 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-Id header. 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

EventWhen it is sent
person.repliedA 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.connectedA person accepts a connection invitation.
person.meeting_bookedA conversation is marked as a booked meeting.
person.scoredA person's fit against a customer profile has been checked. data is a score.
message.sentA message has gone out to a person, from a campaign step or as a reply.
invite.sentA connection invitation has gone out to a person.
scoring_run.completedA scoring run ends. Fetch the results with GET /scoring_runs/{id}/results.
campaign.launchedA campaign is launched. Sending starts later, on the campaign's schedule.
campaign.pausedA campaign is paused, by you or by done. data.reason is you, account_disconnected or account_restricted.
campaign.resumedA paused campaign becomes active again.
campaign.completedA campaign becomes completed.
campaign.person_finishedA person's journey in a campaign ends. data.outcome is sequence_finished, replied, meeting_booked or stopped.
scout.run_finishedA scout finishes one run. Read the people with GET /scouts/{id}/people.
scout.stoppeddone. stops a scout. data.stopped_because says why.
scout.first_look_readyA scout that checks fit has made its first pass and waits for your decision.
draft.waitingA draft starts waiting for approval.
goal.proposedA goal's plan is ready. Nothing has been created or sent yet.
webhook.testYou call POST /webhooks/{id}/test.

The API reference shows the full body of each event.

On this page