Skip to main content
Webhooks are HTTP POST requests OptimalDial sends to a URL you control whenever something interesting happens to one of your uploads. They’re the right way to learn about state changes — polling works, but webhooks arrive within seconds and don’t burn rate-limit budget. This guide covers everything on the receiving side: what we send, how to verify it, how we retry, and the small set of decisions that separate a brittle integration from a sturdy one. For the configuration endpoints (registering URLs, listing deliveries) see the Webhooks reference.

Event types

EventWhen it firesNotable payload fields
upload.createdA new upload row is inserted. Fires for both API and web sources.data.upload.source
upload.completedProcessing finishes successfully.data.upload.downloads.processed, data.upload.downloads.processed_filtered (signed URLs)
upload.failedProcessing fails.data.error_message
webhook.pingSent once at endpoint registration to verify reachability.data.challenge
Pings are sent only during POST /api/v1/webhooks and PATCH /api/v1/webhooks/{id} (when the URL changes). They are never re-sent later — your receiver must be live before you register.

Event envelope

Every event has the same outer shape. The data object is event-specific.
{
  "id": "evt_a1b2c3d4e5f67890abcdef12",
  "type": "upload.completed",
  "api_version": "2026-04-23",
  "created_at": "2026-04-24T12:34:56.789+00:00",
  "data": {
    "upload": {
      "id": "f3c9b8a7-1234-4abc-9def-0123456789ab",
      "source": "api",
      "status": "completed",
      "original_filename": "april-cohort.csv",
      "valid_row_count": 12000,
      "invalid_row_count": 37,
      "organization_id": "01HABCD…",
      "created_at": "2026-04-24T12:30:00+00:00",
      "downloads": {
        "processed": "https://storage.example.com/...?signed=...",
        "processed_filtered": "https://storage.example.com/...?signed=..."
      }
    }
  }
}
Sample bodies for each event type:
{
  "id": "evt_d4e5f67890abcdef12345678",
  "type": "upload.created",
  "api_version": "2026-04-23",
  "created_at": "2026-04-24T12:30:00+00:00",
  "data": {
    "upload": {
      "id": "f3c9b8a7-1234-4abc-9def-0123456789ab",
      "source": "api",
      "status": "ready_for_processing",
      "original_filename": "april-cohort.csv",
      "total_rows": 12037,
      "valid_row_count": 12000,
      "organization_id": "01HABCD…",
      "created_at": "2026-04-24T12:30:00+00:00"
    }
  }
}
Note: the created payload has total_rows (everything you submitted) and valid_row_count. It does not include download URLs — those only appear once processing finishes.

Headers we send

Every delivery includes:
HeaderExampleMeaning
Content-Typeapplication/jsonAlways JSON; body is a single object.
User-AgentOptimalDial-Webhooks/1.0Identifies the sender.
X-OptimalDial-Signaturet=1714000000,v1=2c9a…7b8HMAC-SHA256 of {timestamp}.{body} — see signature verification.
X-OptimalDial-Eventupload.completedThe event type. Same as body.type.
X-OptimalDial-Event-Idevt_a1b2c3d4…The event identifier. Same as body.id.
X-OptimalDial-Delivery-Idb8e1c2a3-…Unique per delivery attempt sequence. Multiple attempts to the same endpoint share this ID.
X-OptimalDial-Attempt11-indexed attempt counter.
X-OptimalDial-Timestamp1714000000The Unix timestamp embedded in the signature.
You should log X-OptimalDial-Delivery-Id with every received webhook — it’s the fastest way for us to debug a delivery problem with you.

Signature verification

The signature is HMAC-SHA256 of f"{timestamp}.{body}" keyed by your endpoint’s secret, in Stripe-compatible format. Verifying it has three parts:
  1. Parse the t and v1 values out of the X-OptimalDial-Signature header.
  2. Reject if the timestamp is more than 5 minutes off from your server clock (replay protection).
  3. Recompute the HMAC and compare it to v1 with a constant-time comparison.
Always verify against the raw request body bytes, before any framework parses or re-serialises the JSON. Pretty-printing or re-ordering keys breaks the signature.
import crypto from "node:crypto";
import express from "express";

const app = express();

// CRITICAL: keep the raw body around — JSON.parse changes it
app.use("/optimaldial-webhook", express.raw({ type: "application/json" }));

const SECRET = process.env.OPTIMALDIAL_WEBHOOK_SECRET!;
const REPLAY_WINDOW_SECONDS = 300;

function verify(rawBody: Buffer, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map(kv => kv.split("=", 2)),
  );
  const ts = Number(parts.t);
  const provided = parts.v1;
  if (!ts || !provided) return false;
  if (Math.abs(Date.now() / 1000 - ts) > REPLAY_WINDOW_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody.toString("utf8")}`)
    .digest("hex");

  // Constant-time comparison; throws if lengths differ, so guard first
  if (expected.length !== provided.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}

app.post("/optimaldial-webhook", (req, res) => {
  const signature = req.header("X-OptimalDial-Signature") ?? "";
  if (!verify(req.body, signature, SECRET)) {
    return res.status(401).send("invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // …handle event…
  res.status(204).end();
});
If you’re rolling your own verifier in another language, stick to these rules and you’ll match our wire format exactly:
  • Sign over f"{timestamp}.{body}" — the dot is literal.
  • HMAC-SHA256, output as lowercase hex.
  • Reject when |now - timestamp| > 300.
  • Use a constant-time comparator (hmac.compare_digest in Python, crypto.timingSafeEqual in Node, etc.).

Retry policy

Each delivery has its own retry schedule. We attempt up to 6 times with the following backoff between attempts:
AttemptTime after the previous attempt
10 (initial send)
21 minute
35 minutes
430 minutes
52 hours
612 hours
After the 6th attempt fails, the delivery is marked exhausted and we stop trying. Total wall-clock window from first attempt to last is approximately 14 hours and 36 minutes. Per-attempt timeout: 10 seconds. If your endpoint hasn’t responded in that window, we count the attempt as failed and move it onto the next backoff.

Which responses count as success vs. retryable vs. permanent

Receiver returnedWe treat it as
2xxDelivered. Future events will continue to fire.
4xx (except 408 and 429)Permanent failure. We do not retry. The delivery is marked exhausted immediately and we bump the consecutive-failure counter on the endpoint.
408, 429, 5xx, network error, timeoutRetryable. We schedule the next backoff.
Followed redirectWe do not follow redirects. Configure your final URL directly.

Auto-disable after sustained failure

We keep a consecutive_failures counter on each endpoint. Every failed attempt (whether the delivery eventually succeeded on retry or got permanently rejected) increments it; every successful delivery resets it to 0. After 20 consecutive failures we set is_active: false and stamp disabled_at on the endpoint. No further events go out until you re-enable it via PATCH /api/v1/webhooks/{id} with {"is_active": true} (which also resets the counter and clears disabled_at). This is a backstop for outages — if your receiver is down for a sustained period, we’d rather pause than carpet-bomb you with retries when you come back. Anything queued during the outage that exhausted its retries is gone; reconcile by listing uploads with GET /api/v1/uploads and looking at their statuses.

URL safety

When you register a webhook endpoint we validate the URL before accepting it:
  • Scheme must be https://. Plain http:// is rejected unless the host appears in the server’s WEBHOOK_ALLOW_HTTP_HOSTS allow-list (used for local development on the OptimalDial side; never enabled in production).
  • DNS resolution must return a public IP. We reject:
    • Private ranges (RFC 1918, RFC 4193 unique-local IPv6).
    • Loopback (127.0.0.0/8, ::1).
    • Link-local (169.254.0.0/16, fe80::/10).
    • Multicast and reserved/unspecified addresses.
    • Cloud metadata endpoints: 169.254.169.254 (AWS, GCP, Azure), 100.100.100.200 (Alibaba), fd00:ec2::254 (IPv6 metadata).
  • Redirects are not followed. Whatever URL you register is the URL we POST to.
These rules apply on every delivery as well as on registration — if your DNS later flips to a private IP, the delivery will fail and the failure counter will tick up.

Best practices

  1. Idempotency. Each event carries an id (also in X-OptimalDial-Event-Id). We may send the same event more than once if a previous attempt was unacknowledged — store the IDs you’ve already processed and de-dupe on receipt. A 24-hour TTL on the de-dupe set is plenty.
  2. Respond fast, process async. Return 2xx as soon as you’ve persisted the event ID and queued the work. Real processing (downloading large CSVs, mutating your DB) should not happen inside the request handler — you have 10 seconds total before we count the attempt as failed.
  3. Always verify the signature first. Even before parsing the body. An unverified webhook is just an arbitrary HTTP request to a URL someone scraped from your DNS records.
  4. Log the delivery ID. X-OptimalDial-Delivery-Id is the fastest way for us to find the row in our delivery table when you ask “did you actually try to send me this?”.
  5. Don’t 5xx on unknown event types. If we add a new event type, you don’t want every delivery to start failing — return 2xx and ignore unknown types until you’re ready to handle them.
  6. Use one secret per endpoint. Each registered endpoint has its own secret. Don’t share them, and don’t try to re-use a deleted endpoint’s secret on a new one.
  7. Re-fetch download URLs. The signed URLs in upload.completed payloads are short-lived. If you can’t process the file immediately, store the upload.id and call GET /api/v1/uploads/{id}/download/processed later for a fresh URL.
  8. Handle webhook.ping like any other event. It arrives at registration time and carries no business meaning — just respond 2xx so the registration completes.