Get events with webhooks
A webhook is a message we send to your server when something happens: an email goes out, a reply arrives, a campaign pauses. Start with the smallest one, a test.
Ask the owner to open Settings › Webhook and press Send a test event. Your server receives one POST with this body:
{
"id": "9d1c4b52-3f6e-4a7b-8c21-5e0f7a6b3d19",
"type": "webhook.test",
"seq": 0,
"generation": "70000000-7000-4700-8700-700000000000",
"timestamp": "2026-10-02T14:30:00.000Z",
"data": {
"test": true,
"message": "This is a test from colding."
}
}id is the ID of this delivery. type is webhook.test and seq is 0, because a test is not an event: real events start at 1. generation is the ID of the run of event numbers the feed is on; the section on restores below explains it. data holds a fixed English sentence.
Before you trust a POST, check that it came from us. verifyWebhook takes three things: payload, the body exactly as it arrived; signature, the value of the x-colding-signature header; and secret, the whsec_ signing secret the owner copied when the webhook was saved. It returns the parsed event, or throws.
import { verifyWebhook } from "colding";
async function receive(request: Request): Promise<Response> {
const event = await verifyWebhook({
payload: await request.text(),
signature: request.headers.get("x-colding-signature"),
secret: "whsec_the_secret_you_were_shown",
});
console.log(event.id, event.type);
return new Response("ok");
}Read the body as text and check it before anything parses it. A framework that parses the JSON and writes it back changes the bytes, and the check fails with no_match.
Set it up
The owner sets the webhook up in Settings › Webhook: an https address your server answers, on port 443 (the default) or 8443, and the events to send. The signing secret is shown once, when the webhook is saved or the secret is replaced. Store it where your server can read it.
A key cannot save, change or test a webhook, and there is no handoff for it. If you are an agent, ask the owner to set it up and to put the secret where your server reads it.
Your server answers every POST with a 2xx status, as soon as it has the body. Do the slow work after you answer.
What a delivery looks like
A delivery is one POST with a JSON body and three headers.
The body is { id, type, seq, generation, timestamp, data }. id is the delivery's ID. type is the event's type. seq is the event's number in the event list, the same one events.list shows. generation is the run of numbers that seq belongs to, the same value events.list answers. timestamp is when the event was recorded, not when this try was made. data is small: IDs and a few facts, such as a recipient's address or a clicked link. It never holds the subject or the words of a message.
The headers:
x-colding-delivery- The delivery's ID, the same as the body's
idand the same on every try. x-colding-event- The event's type, the same as
type. Read it to route a POST before you open the body. x-colding-signature- The proof, like
t=…,v1=…. Give it toverifyWebhook.
A delivery can arrive twice. Keep the id of every delivery you handle, and skip a repeat.
Check a delivery
When verifyWebhook can't trust a delivery, it throws a ColdingWebhookError. Answer 400 and do nothing with the body, unless the code is bad_secret. Its code says why:
missing_header- There is no signature header, or it is empty. Only our deliveries carry one.
bad_format- The header isn't like
t=…,v1=…. too_old- The delivery was signed longer ago than the window allows. Someone may be replaying a captured one.
too_new- It was signed further ahead than the window allows. Check your server's clock.
no_match- No signature matches. Either the secret is wrong, or the body changed on the way, as when a framework parses the JSON and writes it back.
bad_body- The signature is right, but the body is not JSON.
bad_secret- The secret you passed is empty. This is your setup, not the delivery, so answer 500.
Catch the error, answer, and keep going:
import { ColdingWebhookError, verifyWebhook } from "colding";
async function receive(request: Request, secret: string): Promise<Response> {
try {
const event = await verifyWebhook({
payload: await request.text(),
signature: request.headers.get("x-colding-signature"),
secret,
});
console.log(event.id, event.type);
return new Response("ok");
} catch (error) {
if (!(error instanceof ColdingWebhookError)) throw error;
// bad_secret is your setup, not the delivery, so it answers 500.
return new Response(error.code, { status: error.code === "bad_secret" ? 500 : 400 });
}
}The window is 5 minutes either way, unless you change it. Each try is signed again, so a retry that comes later is not refused for being old. If your server's clock runs off, fix the clock first. If you can't, toleranceSeconds widens the window:
const event = await verifyWebhook({ payload, signature, secret, toleranceSeconds: 600 });Send a test event
The owner presses Send a test event in Settings › Webhook. It shows you that your server receives a POST and passes the check, before a real event has to.
- It is signed like a real delivery, so the code that checks a real one checks this one.
- Its body is the one at the top of this page:
typeiswebhook.test,seqis 0, anddataholdstest: trueand a message. - It is not an event.
events.listnever shows it, and a webhook can't subscribe to it. - It is tried once. If your server fails it, we don't retry, and it doesn't count toward switching the webhook off. Press the button again.
- It goes out even while the webhook is switched off, and it doesn't switch it back on.
- A workspace can send 10 test events an hour. Past that, Send a test event is refused until the hour ends.
- Its result is in the delivery list, marked
webhook.test, with what your server answered. If your server saw nothing, after 5 minutes the row says the test never went out. Send a new test.
Retries and switching off
A 2xx answer means you have it. We don't follow redirects, so a 3xx stops the delivery at once. So does a 4xx, except 408, 425 and 429, which mean try again later.
Anything else is tried again, up to 8 tries in all, the first included. The waits between them are 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours: about 28 hours from the first try to the last. If your server sends a Retry-After header that asks for longer, we wait that long, up to 10 hours. After the last try, the delivery is failed.
Your server is sent at most 2 deliveries at once. When more are due, the rest wait their turn and go as soon as one finishes. Waiting is not a try: it uses none of the 8 and doesn't count toward switching the webhook off.
We switch the webhook off when deliveries to it keep failing for 5 days with none landing. The days count from the first delivery that ran out of tries, and a delivery that lands starts the count again. After you fix your server, the owner presses Switch it back on in Settings › Webhook.
When we switch it off, the owner gets an alert, and an email unless they turned alert email off. Settings › Webhook says since when. Switching it back on sends only new events. Nothing it missed goes out again unless the owner asks: Settings › Webhook then offers to send the events written while it was off, and to send every failed delivery again. You can resend only event types your webhook subscribes to now, and only while the event is in the event list (24 hours). Each press counts toward the 60 resends an hour.
That page also lists every delivery with its status, what your server answered and, for a failed one, why. It keeps 30 days, and you can filter it by status and by event type. Send again tries a failed delivery once more, right away. A workspace can use it 60 times an hour. It works only while the event is in the event list. We keep events at least 24 hours. After that, the button may no longer appear.
When your server missed something
A webhook tells your server what happened. If the server is down, a POST gets lost or every delivery attempt fails, you can still read the event in the event list.
On your first call, leave cursor out: you start at the oldest event we still keep. After you have handled the page, save the resumeCursor you get back. After a gap, call events.list with it as cursor. You get everything after that point, oldest first. A key with read can do this.
const page = await colding.events.list({ cursor: lastCursor });
for (const event of page.items) console.log(event.seq, event.type);
// Save this after you handle the page, and pass it as `cursor` next time.
console.log(page.resumeCursor);Read the event list at least once every 24 hours: we keep events for at least that long. After that, an event may no longer be on the list. Once it is gone, a failed delivery of it can't be sent again.
Use seq to find a POST's event in the event list. The POST's id names the delivery, not the event.
A cursor works only with the types and the order it came from. order: "desc" reads back from the newest event. A cursor also belongs to one generation, and a restore ends it.
Keep resumeCursor, not nextCursor. nextCursor pages through one read, and it is null on a page that isn't full, so it can't tell you where to pick up. Every page read oldest first has a resumeCursor, an empty page too. A read with order: "desc" is for browsing, and has none.
After we restore from a backup
Sometimes we put the database back from a backup. The event numbers (seq) then start again from an older point, and the same number can name a different event from the one you saw before.
So that this never goes unnoticed, every page of events.list and every delivery carries a generation. It is the ID of one run of event numbers. A cursor or a saved seq only means something inside the generation it came from.
After a restore, events.list answers a cursor from the old generation with status 409, messageKey serverErrors.feedResetRequired, details.resetRequired set to true and the new generation in details.generation. A cursor from before generations existed gets the same answer. We never read an old cursor as if it still fit.
When you get that answer, or see a new generation in a delivery:
- Keep the
generationof the last page or delivery you handled. A different one means the numbers started over. - Stop acting on events, and throw away your saved cursor.
- Check your own records for what the restore may have lost: your mailboxes' Sent folders, the send list and your handoffs. A send or an approval you can't find is not permission to do it again. If you can't tell what happened, ask the owner.
- Then call
events.listwith no cursor. After you handle the page, save itsresumeCursorandgeneration. - Keep skipping repeats by
id. A delivery that waited through a restore keeps itsidand its event's ID, and carries the newgeneration.
In the SDK, a reset is a ColdingError with status 409. This reads the list again without a cursor, once you have checked your records:
let page;
try {
page = await colding.events.list({ cursor: lastCursor });
} catch (error) {
// 409: we restored from a backup, and this cursor is from before it.
if (!(error instanceof ColdingError) || error.status !== 409) throw error;
// Stop acting on events and check your mailboxes, the send list and your handoffs first.
// A send or an approval you can't find is not permission to do it again.
page = await colding.events.list();
}
// Save both after you handle the page. A different generation next time means the numbers started over.
console.log(page.generation, page.resumeCursor);A reset does not repair a loss. We restore to the last point we have. An event, or an email, from just before that point may not exist in the new database, and the event list cannot show what it does not hold. Reading the list again tells you what is there now. It does not tell you what is missing.
We also pause the campaigns that were running. An email that may have been on its way is marked unknown and is never sent again. Before you start a campaign again, compare your mailboxes' Sent folders with the send list.
A reset is not one of the three send guards. The HTTP request key, the send intent and the worker's claim work as the send guide says, and a reset replaces none of them.
The events
The owner chooses which of these events the webhook receives. Every event appears in the event list, even when it isn't sent to the webhook. If an event type was added after the webhook was saved, the owner must select it for the webhook to receive it.
With these six events, an agent can wait for a webhook instead of checking the event list over and over: campaign.finished, campaign.waiting_for_leads, mailbox.recovered, mailbox.address_confirmed, email_check.run_held and workspace.posture_changed.
send.sent- The mail server a message left through accepted it: your mailbox's own, or ours for a sender address or an agent inbox. It does not mean the message reached an inbox.
send.failed- A message didn't go out. The event says why.
send.unknown- The connection died while we handed the message over. It may have arrived, so never send it again.
send.cancelled- A message was stopped just before it went out. The event says why.
send.bounced- The recipient's server refused a message for good.
send.delivered- The recipient's server accepted an email that left through our own mail servers. Email sent from a mailbox you connected never fires it.
send.deferred- The recipient's server said not now, and our mail server tries again by itself. Don't send the email again.
send.opened- A person opened a message for the first time.
send.clicked- A person clicked a link in a message for the first time.
handoff.completed- A person finished what an agent asked them to do.
mailbox.failed- We couldn't read a mailbox. The event says why.
mailbox.warmup_health_changed- A mailbox's warmup health changed, or its quarantine or block got a new end date.
message.received- A new message arrived in a mailbox.
suppression.added- An address or domain went on the suppression list.
suppression.removed- An address or domain came off the suppression list.
campaign.reply_received- Someone answered a campaign email, or their server answered for them.
campaign.paused- A running campaign was paused. The event says why, unless a person or a key paused it.
email_check.run_completed- A batch of address checks has nothing left to do.
email_check.run_held- A batch of address checks is still open, but nothing in it is moving and some checks wait on hold. The event says why and how many wait. It comes once for each hold, and
run_completedstill comes when the checks finish. campaign.finished- Every lead got the whole sequence, so the campaign finished.
campaign.waiting_for_leads- A running campaign has nobody left to write to.
mailbox.recovered- A problem with a mailbox ended.
mailbox.address_confirmed- A mailbox's address was confirmed, so it can send.
workspace.posture_changed- We put the workspace's sending on hold, changed that limit, or lifted it. The event says which and why.
mail_domain.state_changed- A mail domain you added moved to a new state:
pending,active,failingorremoved. The event gives the domain's ID, name and use (inboxorsend), and the states it movedfromandto. mailbox.review_changed- A limit on a mailbox started or ended.
modeishold(nothing sends from the mailbox),first_contact_brake(an agent inbox replies only to people your workspace wrote to before) or null once nothing limits it. On a hold,groundsays why. Withspam_reports,phishing_malware,legal_orderorother, our team held this mailbox after a report about it. Withcomplaint_rate,bounce_rate,policy_bounce_rateorpolicy_abuse_burst, too many emails from the domain of an agent inbox or a sender address were marked as spam, bounced or refused as spam, so every agent inbox and sender address on that domain is on hold. Otherwisegroundis null, and events sent before we added it don't have it. Whenreceivingis true, our team also stopped the address taking mail.
Every send event (send.sent, send.failed, send.unknown, send.cancelled and send.bounced) carries viaOurServers. It is true when the email left, or would have left, through our own mail servers: a sender address's email or an agent inbox's reply. It is false for a mailbox you connected. Events recorded before the field existed don't have it. When it is true, send.sent means our mail server took the email. When it is false, it means your mailbox's mail server took it. Neither means the email reached an inbox.
send.delivered and send.deferred exist only for email that leaves through our own mail servers, such as a sender address's email or an agent inbox's replies. Each fires at most once per send, even when the recipient's server asks us to try again many times. Both carry sendId, to, messageId, at (when our mail server wrote the answer down) and receiver: google, microsoft, yahoo, icloud or the server's own name, or null when there is none. send.deferred adds smtpCode, enhancedCode and errorDetail, what the recipient's server said. A send can fire send.deferred and then send.delivered or send.bounced. The last one is how it ended.
Two deliveries, one email
A repeat delivery is not a repeat send. But if your handler sends an email when an event arrives, a repeat can ask for a second one.
Sending has its own three guards: the HTTP request key, the send intent and the worker's claim. They are separate, and none of them replaces your check on id. Make the send name one email: a draft's ID and revision already do, and for a test or transactional email you can build the intentKey from the event's generation and seq.