Skip to content
SimpleCold

API docs

Integrations

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:

  • get the final result of a single or batch check that answered processing, instead of polling /single/result
  • know when a bulk job is done, instead of polling /bulk/:id/status
  • show bulk progress in your own UI
  • top up before your credits run out

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.

MethodPathWhat it does
GET/api/v1/webhooks/endpointsList endpoints
POST/api/v1/webhooks/endpointsCreate an endpoint. The response contains the signing secret, once
GET/api/v1/webhooks/endpoints/:idGet one endpoint
PATCH/api/v1/webhooks/endpoints/:idChange url, description, events or enabled
DELETE/api/v1/webhooks/endpoints/:idDelete an endpoint and its delivery log
POST/api/v1/webhooks/endpoints/:id/rotate-secretIssue a new secret (see Rotating the secret)
POST/api/v1/webhooks/endpoints/:id/testSend a ping event
GET/api/v1/webhooks/endpoints/:id/deliveries?limit=25Recent deliveries, newest first
GET/api/v1/webhooks/endpoints/:id/deliveries/:deliveryIdOne delivery with every attempt and the payload
POST/api/v1/webhooks/endpoints/:id/deliveries/:deliveryId/resendSend that event again, with the same event id
GET/api/v1/webhooks/event-typesThe event types you can subscribe to
Create an endpoint
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"]
  }'
Response · 201
{
  "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:

  • Up to 5 endpoints per organisation.
  • The URL must use https and resolve to a public address. Private, loopback, link-local and cloud metadata addresses are rejected, both when you save the endpoint and on every delivery.
  • Use port 443, port 80 or a port from 1024 to 65535. Redirects are not followed.

Events

TypeWhen it is sent
result.readyA single or batch check answered processing, and its final result is now known.
bulk.finishedA bulk job completed, failed or was canceled.
bulk.progressA bulk job is running. At most once every 30 seconds per job, and only when progress moved. Subscribe only if you show progress.
credits.lowYour available credits dropped below the low-credit threshold. Sent once each time the balance crosses it.
pingOnly when you click Send test event or call the test route. You don't subscribe to it.

Every event has the same envelope:

FieldTypeDescription
idstringUnique event id, evt_ followed by 24 hex characters. Stays the same across retries and resends.
typestringOne of the types above.
creatednumberWhen the event happened, in Unix seconds.
orgIdstringYour organisation id.
livemodebooleantrue for live activity, false for events caused by a test API key. Ignore false events in production.
dataobjectThe 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.

result.ready
{
  "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

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"
  }
}
  • status is completed, canceled or failed.
  • counts are rows per result category. On a canceled job, unknown results are not charged, so creditsUsed can be lower than totalEmails.
  • downloads are paths on the API host. Call them with your API key, as in Download the result. They are null for a failed job, which uses no credits.

bulk.progress

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

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

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

HeaderExampleDescription
Content-Typeapplication/jsonThe body is UTF-8 JSON.
User-AgentSimpleCold-Webhooks/1.0 (+https://simplecold.com/dashboard/docs/webhooks)Identifies SimpleCold.
SimpleCold-Signaturet=1793001234,v1=5257a8...Signature of the body. See below.
SimpleCold-Event-Idevt_9b1f0c2d3e4a5b6c7d8e9f00Same as id in the body. Use it to dedupe.
SimpleCold-Event-Typeresult.readySame as type in the body.
SimpleCold-Webhook-Version2026-10-26Payload version. A breaking change to payloads gets a new version.
SimpleCold-Delivery-Id0b8f...This delivery. A resend gets a new delivery id but keeps the event id.
SimpleCold-Delivery-Attempt1Attempt number, starting at 1.

Verify the signature

SimpleCold-Signature looks like t=1793001234,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd.

  • t is the Unix time the request was signed.
  • v1 is the hex HMAC-SHA256 of `${t}.${rawBody}` with your signing secret as the key.
  • Right after a secret rotation there are two v1 values, one per secret. Accept the request if any of them matches.

To verify:

  1. Read the raw request body as bytes or a string. Do not parse and re-serialize the JSON first; the signature covers the exact bytes we sent.
  2. Split the header on ,, take t and every v1.
  3. Compute HMAC-SHA256 of t + "." + rawBody with your secret.
  4. Compare it with each v1 using a constant-time comparison.
  5. Reject the request if t is more than 5 minutes away from your clock. This stops replays of old requests.
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
});

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:

Retry12345678
After1 min5 min15 min1 h3 h6 h12 h24 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:

  • 410 Gone means "stop sending". The delivery stops and the endpoint is turned off.
  • If an endpoint fails 20 events in a row over more than 3 days, we turn it off and email your team. One successful delivery resets the count.

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

  • Answer fast. Return 200 as soon as the signature checks out, then do the work in a background job. Slow handlers hit the 10 second limit and get retried.
  • Dedupe by event id. A delivery can arrive more than once (a retry after a timeout you did process, or a resend). Store SimpleCold-Event-Id and skip ids you have already handled.
  • Verify every request and reject old timestamps. Keep the secret in your secret store, not in code.
  • Don't rely on order. Check created, or fetch the current state from the API when it matters.
  • Subscribe to what you use. Leave bulk.progress off unless you show progress.
  • Keep polling as a fallback for critical flows. If your endpoint was down for longer than the retry window, the API still has every result.

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.

Webhooks | SimpleCold API docs