Eazip
Eazip.jsPricingSign inStart free

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

  1. Log in to your Eazip dashboard
  2. Navigate to Webhooks in the sidebar
  3. Click Add Endpoint and enter your HTTPS endpoint URL
  4. 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 2xx status code.

When Webhooks Are Sent

A webhook is fired whenever a ZIP job reaches a terminal state:

EventTrigger
job.completedZIP file is ready for download
job.failedJob 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

FieldTypeDescription
delivery_idstringUnique UUID for this webhook delivery. Use this for idempotency checks — the same delivery_id is sent on retries of the same delivery.
eventstring"job.completed" or "job.failed"
job_idstringUUID of the ZIP job
statusstringJob status (completed or failed)
url_countnumberNumber of URLs submitted in the job
file_countnumber | nullNumber of files actually packaged (null if the job failed before packaging)
errorsarray | nullArray of { url, error } objects for failed file fetches. null when there are no errors.
download_urlstring | nullSigned download URL for the ZIP. null for split jobs (use zips[] instead) and for failed jobs.
metadataobject | nullThe key/value pairs you passed when creating the job
timestampstringISO 8601 timestamp of when the event was generated
multi_zipbooleantrue when the job was created with max_zip_size_bytes and produced multiple ZIPs.
zip_countnumberNumber of ZIPs in zips[] (always 1 for non-split jobs).
total_sizenumber | nullSum of every ZIP's size in bytes. null when the job failed before any ZIP was produced.
zipsarrayOne 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

  1. Eazip computes HMAC-SHA256(request_body, your_signing_secret) and hex-encodes the result.
  2. The hex string is sent in the X-Webhook-Signature header.
  3. 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) }
end
import (
	"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

  1. On the webhook detail page click Rotate Secret, choose a grace period (1–24 hours), and click Rotate.
  2. Eazip generates a Pending Secret. Both it and the Active Secret are shown on the detail page.
  3. During the grace period every delivery includes two comma-separated signatures in X-Webhook-Signature — one active, one pending.
  4. Update your server to verify against the new secret.
  5. 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-2xx status code (e.g. 500, 503, 429)
  • Connection timeout (no response within 10 seconds)
  • Connection error (endpoint unreachable)

Delivery Status

StatusDescription
pendingDelivery in progress or scheduled for retry
successEndpoint returned a 2xx response
failedAll 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:

ColumnDescription
EventEvent type (e.g. job.completed)
Statuspending (yellow), success (green), or failed (red)
HTTP StatusThe HTTP status code returned by your endpoint (blank if no response was received)
AttemptsNumber of delivery attempts made so far
SentWhen 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

  1. Always verify signatures — never trust a webhook without checking the HMAC signature.
  2. Respond quickly — return 200 immediately and process the payload asynchronously; a slow handler times out and is retried.
  3. Handle duplicates — deduplicate on delivery_id, or on job_id to deduplicate per job.
  4. Use HTTPS — required, and it keeps the payload (including download_url) encrypted in transit.
  5. Rotate secrets regularly — the grace period makes rotation zero-downtime.
  6. Monitor deliveries — check delivery history to catch persistent failures early.