Event types
| Event | When it fires | Notable payload fields |
|---|---|---|
upload.created | A new upload row is inserted. Fires for both API and web sources. | data.upload.source ∈ |
upload.completed | Processing finishes successfully. | data.upload.downloads.processed, data.upload.downloads.processed_filtered (signed URLs) |
upload.failed | Processing fails. | data.error_message |
webhook.ping | Sent once at endpoint registration to verify reachability. | data.challenge |
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. Thedata object is event-specific.
- upload.created
- upload.completed
- upload.failed
- webhook.ping
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:| Header | Example | Meaning |
|---|---|---|
Content-Type | application/json | Always JSON; body is a single object. |
User-Agent | OptimalDial-Webhooks/1.0 | Identifies the sender. |
X-OptimalDial-Signature | t=1714000000,v1=2c9a…7b8 | HMAC-SHA256 of {timestamp}.{body} — see signature verification. |
X-OptimalDial-Event | upload.completed | The event type. Same as body.type. |
X-OptimalDial-Event-Id | evt_a1b2c3d4… | The event identifier. Same as body.id. |
X-OptimalDial-Delivery-Id | b8e1c2a3-… | Unique per delivery attempt sequence. Multiple attempts to the same endpoint share this ID. |
X-OptimalDial-Attempt | 1 | 1-indexed attempt counter. |
X-OptimalDial-Timestamp | 1714000000 | The Unix timestamp embedded in the signature. |
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 off"{timestamp}.{body}" keyed by your endpoint’s secret, in Stripe-compatible format. Verifying it has three parts:
- Parse the
tandv1values out of theX-OptimalDial-Signatureheader. - Reject if the timestamp is more than 5 minutes off from your server clock (replay protection).
- Recompute the HMAC and compare it to
v1with a constant-time comparison.
- Node.js / Express
- Python / Flask
- Python / FastAPI
- 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_digestin Python,crypto.timingSafeEqualin Node, etc.).
Retry policy
Each delivery has its own retry schedule. We attempt up to 6 times with the following backoff between attempts:| Attempt | Time after the previous attempt |
|---|---|
| 1 | 0 (initial send) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
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 returned | We treat it as |
|---|---|
2xx | Delivered. 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, timeout | Retryable. We schedule the next backoff. |
| Followed redirect | We do not follow redirects. Configure your final URL directly. |
Auto-disable after sustained failure
We keep aconsecutive_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://. Plainhttp://is rejected unless the host appears in the server’sWEBHOOK_ALLOW_HTTP_HOSTSallow-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.
Best practices
- Idempotency. Each event carries an
id(also inX-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. - Respond fast, process async. Return
2xxas 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. - 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.
- Log the delivery ID.
X-OptimalDial-Delivery-Idis the fastest way for us to find the row in our delivery table when you ask “did you actually try to send me this?”. - Don’t 5xx on unknown event types. If we add a new event type, you don’t want every delivery to start failing — return
2xxand ignore unknown types until you’re ready to handle them. - 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.
- Re-fetch download URLs. The signed URLs in
upload.completedpayloads are short-lived. If you can’t process the file immediately, store theupload.idand callGET /api/v1/uploads/{id}/download/processedlater for a fresh URL. - Handle
webhook.pinglike any other event. It arrives at registration time and carries no business meaning — just respond2xxso the registration completes.