Webhooks
Receive real-time notifications when ZIP jobs complete or fail.
Register an HTTPS endpoint and Eazip POSTs the result to it when a ZIP job finishes — no polling of the Jobs API.
Setup in Dashboard
- Log in to your Eazip dashboard
- Navigate to Webhooks in the sidebar
- Click Add Endpoint and enter your HTTPS endpoint URL
- Click the endpoint to open its detail page and copy the Active Secret
The signing secret stays visible on that detail page.
Requirements
- The endpoint URL must use HTTPS.
- Your server must respond within 10 seconds with a
2xxstatus code.
When Webhooks Are Sent
A webhook is fired whenever a ZIP job reaches a terminal state:
| Event | Trigger |
|---|---|
job.completed | ZIP file is ready for download |
job.failed | Job failed — all files could not be fetched, or a fail-fast error occurred |
Each registered endpoint receives its own delivery independently.
Payload
Every delivery is an HTTP POST with Content-Type: application/json:
{
"delivery_id": "d4e5f6a7-b8c9-4d2e-a1f3-9c8b7a6e5d4f",
"event": "job.completed",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"url_count": 3,
"file_count": 3,
"errors": null,
"download_url": "https://api.eazip.io/download/eyJ...",
"metadata": { "order_id": "ord_123" },
"timestamp": "2025-01-21T10:00:45.000Z",
"multi_zip": false,
"zip_count": 1,
"total_size": 1048576,
"zips": [
{
"id": "zip_a1b2c3d4e5f6",
"sequence": 1,
"filename": "archive.zip",
"size": 1048576,
"download_url": "https://api.eazip.io/download/eyJ..."
}
]
}Payload Fields
| Field | Type | Description |
|---|---|---|
delivery_id | string | Unique UUID for this webhook delivery. Use this for idempotency checks — the same delivery_id is sent on retries of the same delivery. |
event | string | "job.completed" or "job.failed" |
job_id | string | UUID of the ZIP job |
status | string | Job status (completed or failed) |
url_count | number | Number of URLs submitted in the job |
file_count | number | null | Number of files actually packaged (null if the job failed before packaging) |
errors | array | null | Array of { url, error } objects for failed file fetches. null when there are no errors. |
download_url | string | null | Signed download URL for the ZIP. null for split jobs (use zips[] instead) and for failed jobs. |
metadata | object | null | The key/value pairs you passed when creating the job |
timestamp | string | ISO 8601 timestamp of when the event was generated |
multi_zip | boolean | true when the job was created with max_zip_size_bytes and produced multiple ZIPs. |
zip_count | number | Number of ZIPs in zips[] (always 1 for non-split jobs). |
total_size | number | null | Sum of every ZIP's size in bytes. null when the job failed before any ZIP was produced. |
zips | array | One entry per ZIP file: { id, sequence, filename, size, download_url }. Always present (length 1 for non-split jobs). |
Example: Auto-Split Job
{
"delivery_id": "f6e5d4c3-b2a1-9876-5432-10fedcba0987",
"event": "job.completed",
"job_id": "9c4d2f8e-1234-5678-90ab-cdef01234567",
"status": "completed",
"url_count": 1000,
"file_count": 1000,
"errors": null,
"download_url": null,
"metadata": null,
"timestamp": "2026-04-22T10:08:12.000Z",
"multi_zip": true,
"zip_count": 2,
"total_size": 3221225472,
"zips": [
{
"id": "zip_aaa111",
"sequence": 1,
"filename": "photos_01.zip",
"size": 2147483648,
"download_url": "https://api.eazip.io/download/eyJ...AAA"
},
{
"id": "zip_bbb222",
"sequence": 2,
"filename": "photos_02.zip",
"size": 1073741824,
"download_url": "https://api.eazip.io/download/eyJ...BBB"
}
]
}A single webhook delivery is sent per job, not per ZIP. Iterate zips[]
to download each bin.
Example: Failed Job
{
"delivery_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"event": "job.failed",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "failed",
"url_count": 2,
"file_count": null,
"errors": [
{ "url": "https://example.com/missing.pdf", "error": "HTTP 404" }
],
"download_url": null,
"metadata": null,
"timestamp": "2025-01-21T10:01:00.000Z",
"multi_zip": false,
"zip_count": 0,
"total_size": null,
"zips": []
}Verifying Signatures
Every delivery carries an HMAC-SHA256 signature in the X-Webhook-Signature
header. Always verify it before processing the payload — that is what
proves the delivery came from Eazip untampered.
How It Works
- Eazip computes
HMAC-SHA256(request_body, your_signing_secret)and hex-encodes the result. - The hex string is sent in the
X-Webhook-Signatureheader. - Your server computes the same HMAC over the raw request body with your stored secret and compares the two.
Verification Examples
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// During secret rotation, the header may contain two
// comma-separated signatures — check each one.
const signatures = signatureHeader.split(',');
return signatures.some((sig) => {
if (sig.length !== expected.length) return false;
return timingSafeEqual(
Buffer.from(sig, 'utf8'),
Buffer.from(expected, 'utf8'),
);
});
}import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
# During secret rotation, the header may contain two
# comma-separated signatures — check each one.
signatures = signature_header.split(",")
return any(hmac.compare_digest(sig, expected) for sig in signatures)require 'openssl'
def verify_webhook(raw_body, signature_header, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
# During secret rotation, the header may contain two
# comma-separated signatures — check each one.
signatures = signature_header.split(",")
signatures.any? { |sig| OpenSSL.secure_compare(sig, expected) }
endimport (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func verifyWebhook(rawBody []byte, signatureHeader, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
// During secret rotation, the header may contain two
// comma-separated signatures — check each one.
for _, sig := range strings.Split(signatureHeader, ",") {
if hmac.Equal([]byte(sig), []byte(expected)) {
return true
}
}
return false
}Use constant-time comparison
Use constant-time comparison (timingSafeEqual, hmac.compare_digest,
OpenSSL.secure_compare, hmac.Equal) instead of == to prevent timing
attacks.
Secret Rotation
Eazip supports zero-downtime rotation of the signing secret with a grace period.
How It Works
- On the webhook detail page click Rotate Secret, choose a grace period (1–24 hours), and click Rotate.
- Eazip generates a Pending Secret. Both it and the Active Secret are shown on the detail page.
- During the grace period every delivery includes two comma-separated
signatures in
X-Webhook-Signature— one active, one pending. - Update your server to verify against the new secret.
- When the grace period expires the pending secret promotes to active, and only one signature is sent from then on.
While a rotation runs, the Rotate Secret button shows "Rotation in progress" and is disabled. The time left appears as a badge next to Grace Period Remaining.
Timeline
Rotate called Grace period expires
│ │
▼ ▼
─────┬────────────────────────────────┬──────────
│ Both signatures sent │ New secret only
│ (active + pending) │ (pending → active)
─────┴────────────────────────────────┴──────────Only one rotation can be in progress at a time. Rotating while a pending
secret exists returns 409 Conflict.
Retry & Delivery Guarantees
A delivery that does not return 2xx — or times out after 10 seconds — is
retried automatically on a short interval, up to 5 attempts in total.
After 5 failed attempts the delivery is marked permanently failed and no
further retries are attempted.
What Counts as a Failure
- Any HTTP response with a non-
2xxstatus code (e.g.500,503,429) - Connection timeout (no response within 10 seconds)
- Connection error (endpoint unreachable)
Delivery Status
| Status | Description |
|---|---|
pending | Delivery in progress or scheduled for retry |
success | Endpoint returned a 2xx response |
failed | All retry attempts exhausted, or endpoint was deactivated |
Viewing Delivery History
Open a webhook's detail page and select the Delivery History tab. Each delivery shows:
| Column | Description |
|---|---|
| Event | Event type (e.g. job.completed) |
| Status | pending (yellow), success (green), or failed (red) |
| HTTP Status | The HTTP status code returned by your endpoint (blank if no response was received) |
| Attempts | Number of delivery attempts made so far |
| Sent | When the delivery was first created |
The most recent 50 deliveries are shown.
Idempotency
Your endpoint may receive the same event more than once, for example when a
retry follows a lost response. Make your handler idempotent by
deduplicating on delivery_id, which stays the same across retries of one
delivery.
Deduplicate on job_id instead if you want one handler invocation per job
regardless of how many endpoints you have registered.
Best Practices
- Always verify signatures — never trust a webhook without checking the HMAC signature.
- Respond quickly — return
200immediately and process the payload asynchronously; a slow handler times out and is retried. - Handle duplicates — deduplicate on
delivery_id, or onjob_idto deduplicate per job. - Use HTTPS — required, and it keeps the payload (including
download_url) encrypted in transit. - Rotate secrets regularly — the grace period makes rotation zero-downtime.
- Monitor deliveries — check delivery history to catch persistent failures early.