Webhooks
Receive real-time notifications when ZIP jobs complete or fail.
Webhooks let your server receive HTTP callbacks when a ZIP job finishes. Instead of polling the Jobs API, register an endpoint URL and Eazip will POST the result to you automatically.
Setup in Dashboard
- Log in to your Eazip dashboard
- Navigate to Webhooks in the sidebar
- Click Add Endpoint and enter your HTTPS endpoint URL
- After creation, click the endpoint to open the detail page and copy the Active Secret
You can view your signing secret at any time from the webhook 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 |
If you have multiple webhook endpoints registered, each one receives its own delivery independently.
Payload
Every webhook delivery is an HTTP POST with Content-Type: application/json. The body has the following structure:
:::tip[New integrations: read zips[]]
zips[] is the canonical representation of the ZIPs produced by a job and is always present in the payload — length 1 for non-split jobs and length N for auto-split jobs. New clients should iterate zips[] (each entry has its own download_url) rather than the top-level download_url, which is null for split jobs and only kept for non-split jobs as a backward-compatibility convenience.
:::
{
"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 includes an X-Webhook-Signature header containing an HMAC-SHA256 signature. Always verify this signature before processing the payload to ensure it was sent by Eazip and has not been tampered with.
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 using your stored secret and compares it.
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
}:::tip
Use constant-time comparison (e.g. timingSafeEqual, hmac.compare_digest, OpenSSL.secure_compare, hmac.Equal) instead of == to prevent timing attacks.
:::
Secret Rotation
Over time you may need to rotate your signing secret — for example, after a team member leaves or as part of a regular security policy. Eazip supports zero-downtime rotation with a grace period.
How It Works
- Open the webhook detail page in the dashboard and click Rotate Secret. Choose a grace period (1–24 hours) and click Rotate.
- Eazip generates a new Pending Secret. Both the Active Secret and the new Pending Secret are displayed on the detail page.
- During the grace period, every delivery includes two comma-separated signatures in
X-Webhook-Signature— one computed with the active secret and one with the pending secret. - Update your server to verify against the new secret.
- When the grace period expires, the pending secret automatically promotes to the active secret. From that point, only one signature is sent.
While a rotation is in progress, the Rotate Secret button shows "Rotation in progress" and is disabled. The remaining grace period is displayed 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. Attempting to rotate while a pending secret exists returns 409 Conflict.
Retry & Delivery Guarantees
If your endpoint does not return a 2xx response (or the request times out after 10 seconds), Eazip will automatically retry the delivery.
Retries are processed on a short interval. Up to 5 attempts are made in total. After 5 failed attempts, the delivery is marked as 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
Each delivery has one of these statuses:
| 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 in the dashboard 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 (e.g. due to retries after a network issue where the response was lost). Design your handler to be idempotent — use delivery_id as a deduplication key to avoid processing the same delivery twice. The delivery_id stays the same across retries of the same delivery. You can also use job_id if you only need one handler invocation per job regardless of how many endpoints you have registered.
Best Practices
- Always verify signatures — never trust incoming webhooks without checking the HMAC signature.
- Respond quickly — return a
200immediately and process the payload asynchronously. If your handler takes too long, the delivery will time out and be retried. - Handle duplicates — use
delivery_idto deduplicate deliveries in case of retries (orjob_idto deduplicate per job). - Use HTTPS — webhook URLs must use HTTPS. This ensures the payload (including
download_url) is encrypted in transit. - Rotate secrets regularly — use the grace period mechanism to rotate secrets without downtime.
- Monitor deliveries — check delivery history to catch persistent failures early.