Webhooks
On this page
Webhooks push events to your server, so you don't have to poll. Add an HTTPS endpoint, choose the events it should receive, and SimpleCold sends a signed POST with a JSON body whenever one of them happens.
Use them to:
Set up an endpoint
In the dashboard, open Settings → Webhooks and click Add endpoint. Enter the URL, choose the events, and copy the signing secret. It is shown only once.
You can also manage endpoints from code with your API key. Every route takes Authorization: Bearer YOUR_API_KEY and works on the key's organisation.
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/webhooks/endpoints | List endpoints |
POST | /api/v1/webhooks/endpoints | Create an endpoint. The response contains the signing secret, once |
GET | /api/v1/webhooks/endpoints/:id | Get one endpoint |
PATCH | /api/v1/webhooks/endpoints/:id | Change url, description, events or enabled |
DELETE | /api/v1/webhooks/endpoints/:id | Delete an endpoint and its delivery log |
POST | /api/v1/webhooks/endpoints/:id/rotate-secret | Issue a new secret (see Rotating the secret) |
POST | /api/v1/webhooks/endpoints/:id/test | Send a ping event |
GET | /api/v1/webhooks/endpoints/:id/deliveries?limit=25 | Recent deliveries, newest first |
GET | /api/v1/webhooks/endpoints/:id/deliveries/:deliveryId | One delivery with every attempt and the payload |
POST | /api/v1/webhooks/endpoints/:id/deliveries/:deliveryId/resend | Send that event again, with the same event id |
GET | /api/v1/webhooks/event-types | The event types you can subscribe to |
curl -X POST "https://simplecold.com/api/v1/webhooks/endpoints" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/simplecold",
"description": "Production CRM sync",
"events": ["result.ready", "bulk.finished"]
}'{
"code": "SUCCESS",
"message": "Successfully created resource.",
"payload": {
"endpoint": {
"id": "3f0c3a4e-7a43-4a55-9d3e-2b6f0f6b8f11",
"url": "https://example.com/webhooks/simplecold",
"description": "Production CRM sync",
"enabled": true,
"events": ["result.ready", "bulk.finished"],
"secretHint": "k3Qa",
"previousSecretExpiresAt": null,
"disabledReason": null,
"disabledAt": null,
"consecutiveFailures": 0,
"lastSuccessAt": null,
"lastFailureAt": null,
"createdAt": "2026-10-26T10:00:00.000Z",
"updatedAt": "2026-10-26T10:00:00.000Z"
},
"secret": "whsec_0dJm9...k3Qa"
}
}Rules for endpoints:
Events
| Type | When it is sent |
|---|---|
result.ready | A single or batch check answered processing, and its final result is now known. |
bulk.finished | A bulk job completed, failed or was canceled. |
bulk.progress | A bulk job is running. At most once every 30 seconds per job, and only when progress moved. Subscribe only if you show progress. |
credits.low | Your available credits dropped below the low-credit threshold. Sent once each time the balance crosses it. |
ping | Only when you click Send test event or call the test route. You don't subscribe to it. |
Every event has the same envelope:
| Field | Type | Description |
|---|---|---|
id | string | Unique event id, evt_ followed by 24 hex characters. Stays the same across retries and resends. |
type | string | One of the types above. |
created | number | When the event happened, in Unix seconds. |
orgId | string | Your organisation id. |
livemode | boolean | true for live activity, false for events caused by a test API key. Ignore false events in production. |
data | object | The event payload, described below. |
result.ready
Sent once per request, not once per address. results holds only the addresses that answered processing; each item has exactly the fields of the single check response. A batch with more than 100 pending addresses is split over several events. Bulk jobs never send result.ready; they send bulk.finished.
{
"id": "evt_9b1f0c2d3e4a5b6c7d8e9f00",
"type": "result.ready",
"created": 1793001234,
"orgId": "6a1c4f0e-2a4b-4b6e-9c1d-0e5f4a3b2c1d",
"livemode": true,
"data": {
"request": "single",
"results": [
{
"email": "[email protected]",
"domain": "example.com",
"status": "mailbox_found",
"statusMessage": "The mailbox exists and accepts mail.",
"category": "deliverable",
"failType": "none",
"mxLocation": "US",
"siteLocation": "US",
"siteLanguage": "EN",
"suggestion": null
}
]
}
}request is single or batch.
bulk.finished
{
"id": "evt_2c4e6a8b0d1f3e5a7c9b1d3f",
"type": "bulk.finished",
"created": 1793004321,
"orgId": "6a1c4f0e-2a4b-4b6e-9c1d-0e5f4a3b2c1d",
"livemode": true,
"data": {
"jobId": "b7e1c2d3-4f5a-4b6c-8d9e-0f1a2b3c4d5e",
"name": "leads-october",
"status": "completed",
"totalEmails": 12000,
"counts": {
"deliverable": 8410,
"risky": 2380,
"undeliverable": 1210
},
"creditsUsed": 12000,
"downloads": {
"csv": "/api/v1/api-validation/bulk/b7e1c2d3-4f5a-4b6c-8d9e-0f1a2b3c4d5e/download-result?output=csv&verifiedOnly=false",
"json": "/api/v1/api-validation/bulk/b7e1c2d3-4f5a-4b6c-8d9e-0f1a2b3c4d5e/download-result?output=json&verifiedOnly=false",
"verifiedCsv": "/api/v1/api-validation/bulk/b7e1c2d3-4f5a-4b6c-8d9e-0f1a2b3c4d5e/download-result?output=csv&verifiedOnly=true",
"verifiedJson": "/api/v1/api-validation/bulk/b7e1c2d3-4f5a-4b6c-8d9e-0f1a2b3c4d5e/download-result?output=json&verifiedOnly=true"
},
"finishedAt": "2026-10-26T11:05:21.000Z"
}
}bulk.progress
{
"id": "evt_4d6f8a0c2e4b6d8f0a2c4e6b",
"type": "bulk.progress",
"created": 1793002000,
"orgId": "6a1c4f0e-2a4b-4b6e-9c1d-0e5f4a3b2c1d",
"livemode": true,
"data": {
"jobId": "b7e1c2d3-4f5a-4b6c-8d9e-0f1a2b3c4d5e",
"name": "leads-october",
"status": "in_progress",
"totalEmails": 12000,
"processed": 5400,
"progressPercent": 45,
"etaSeconds": 610
}
}status is in_progress or canceling. etaSeconds is null until there is enough data to estimate.
credits.low
{
"id": "evt_6f8b0d2a4c6e8a0b2d4f6a8c",
"type": "credits.low",
"created": 1793003000,
"orgId": "6a1c4f0e-2a4b-4b6e-9c1d-0e5f4a3b2c1d",
"livemode": true,
"data": {
"availableCredits": 420,
"threshold": 500
}
}It is sent again only after the balance goes back above the threshold and then drops below it.
ping
{
"id": "evt_8a0c2e4b6d8f0a2c4e6b8d0f",
"type": "ping",
"created": 1793000000,
"orgId": "6a1c4f0e-2a4b-4b6e-9c1d-0e5f4a3b2c1d",
"livemode": true,
"data": {
"message": "Test event from SimpleCold. Your endpoint is reachable and verifying signatures.",
"endpointId": "3f0c3a4e-7a43-4a55-9d3e-2b6f0f6b8f11"
}
}A test event is tried once, without retries, even when the endpoint is turned off.
Headers
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | The body is UTF-8 JSON. |
User-Agent | SimpleCold-Webhooks/1.0 (+https://simplecold.com/dashboard/docs/webhooks) | Identifies SimpleCold. |
SimpleCold-Signature | t=1793001234,v1=5257a8... | Signature of the body. See below. |
SimpleCold-Event-Id | evt_9b1f0c2d3e4a5b6c7d8e9f00 | Same as id in the body. Use it to dedupe. |
SimpleCold-Event-Type | result.ready | Same as type in the body. |
SimpleCold-Webhook-Version | 2026-10-26 | Payload version. A breaking change to payloads gets a new version. |
SimpleCold-Delivery-Id | 0b8f... | This delivery. A resend gets a new delivery id but keeps the event id. |
SimpleCold-Delivery-Attempt | 1 | Attempt number, starting at 1. |
Verify the signature
SimpleCold-Signature looks like t=1793001234,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd.
To verify:
const crypto = require("node:crypto");
const express = require("express");
const SECRET = process.env.SIMPLECOLD_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
function verify(header, rawBody, secret) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.split("=", 2).map((s) => s.trim());
if (key === "t") timestamp = Number(value);
if (key === "v1") signatures.push(value);
}
if (!timestamp || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
const app = express();
// Keep the raw body for this route: verify first, parse after.
app.post("/webhooks/simplecold", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(req.get("SimpleCold-Signature"), rawBody, SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
res.status(200).end(); // answer first, work after
queueForProcessing(event); // your job queue
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["SIMPLECOLD_WEBHOOK_SECRET"] # whsec_...
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify(header: str | None, raw_body: bytes, secret: str) -> bool:
if not header:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
@app.post("/webhooks/simplecold")
def simplecold_webhook():
raw_body = request.get_data() # raw bytes, before any JSON parsing
if not verify(request.headers.get("SimpleCold-Signature"), raw_body, SECRET):
abort(400)
event = json.loads(raw_body)
enqueue(event) # your job queue
return "", 200<?php
$secret = getenv('SIMPLECOLD_WEBHOOK_SECRET'); // whsec_...
$tolerance = 300;
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_SIMPLECOLD_SIGNATURE'] ?? '';
function simplecold_verify(string $header, string $rawBody, string $secret, int $tolerance): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_map('trim', explode('=', $part, 2) + [1 => '']);
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || !$signatures || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
if (!simplecold_verify($header, $rawBody, $secret, $tolerance)) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($rawBody, true);
http_response_code(200);
// Hand $event to your queue here.Delivery and retries
A delivery succeeds when your endpoint answers with any 2xx status within 10 seconds. Anything else counts as a failure: a timeout, a connection error, a 3xx redirect, a 4xx or a 5xx.
Failed deliveries are retried with exponential backoff and ±10% jitter. After the first attempt there are up to 8 retries, at about these times after the first attempt:
| Retry | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| After | 1 min | 5 min | 15 min | 1 h | 3 h | 6 h | 12 h | 24 h |
After the last retry the delivery is marked failed. You can still resend it from the dashboard or the API for 30 days.
Some answers stop the retries:
When an endpoint is off, new events are not queued for it. Turn it back on in the dashboard (or PATCH it with "enabled": true), use Send test event to check it, and resend anything you missed.
We send at most a few requests at a time to each endpoint. Deliveries are not guaranteed to arrive in order; use created if order matters.
Rotating the secret
Rotate secret issues a new secret and shows it once. For the next 24 hours we sign every request with both the new and the old secret, so the header carries two v1 values. Deploy the new secret within that window and nothing is rejected. To expire the old secret at once, call the API with {"overlapHours": 0} (0 to 72 hours).
Delivery log
Each endpoint keeps a log for 30 days: event, status, attempt number, HTTP status code, latency, the first 1,024 characters of your response and the error, if any. Open it under Recent deliveries in the dashboard, or with the deliveries routes above. Deliveries of test events (livemode: false) carry a test chip there and "livemode": false in the API. Resend sends the same event, with the same event id, as a new delivery.
Best practices
IP addresses and egress
Webhooks are sent from SimpleCold's cloud infrastructure. The source addresses are not fixed and can change without notice, so verify the signature instead of allowlisting IPs. If your firewall must allowlist, put a small public receiver in front that verifies the signature and forwards the event.
Requests come from the SimpleCold-Webhooks/1.0 user agent, never follow redirects, and only connect to public addresses. The host name is resolved again for every delivery, so a DNS change takes effect on the next attempt.