Guide · Webhooks

Verify the bytes before trusting the event.

PrintHook signs each event with HMAC-SHA256. Read the request body once as raw bytes, enforce a timestamp window, compare every supplied signature in constant time, then deduplicate and process.

For cURL, provision a private mode-0600 header file from your secret store containing Authorization: Bearer YOUR_INTEGRATION_KEY, and set PRINTHOOK_AUTH_HEADER_FILE to its path. Do not commit it, enable shell tracing, or use verbose request logging. Passing a file keeps the key out of command-line arguments. For language examples, inject PRINTHOOK_API_KEY through your server-side secret manager.

Create a subscription

The receiver must be an absolute public http or https URL; use HTTPS in production. Private, loopback, link-local, metadata, reserved, and special-purpose addresses are blocked. Ask GET /v1/event-types for the current catalog.

cURL
curl --connect-timeout 5 --max-time 20 --fail-with-body --silent --show-error \
  -X POST "https://api.printhook.io/v1/webhooks" \
  --header @"$PRINTHOOK_AUTH_HEADER_FILE" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  --data '{"url":"https://hooks.example.com/printhook","event_types":["job.*"]}'

The 201 response returns the generated secret once. Omitting secret generates a signing key; explicitly sending secret: null creates an unsigned subscription, which this verifier deliberately rejects. Store it immediately in your secret manager. Later reads do not reveal it. You may instead supply secret when creating or patching the subscription; accepted secret lengths are published by GET /v1/meta.

job.* selects the job namespace. A subscription to ["*"] receives the normally announced event types, but some event types are opt-in and must be named literally. Use the event-type catalog rather than hardcoding assumptions.

Read one event per request

Each delivery contains one event object. The body is byte-identical to GET /v1/events/{id}, without an envelope or array. The event id appears in both the body and webhook-id.

webhook-id: evt_01J8ZKC6Q0R4T7V9X1Y3Z5A7B9
webhook-timestamp: 1789467778
webhook-signature: v1,<base64 HMAC-SHA256>
Content-Type: application/json
User-Agent: PrintHook-Webhooks/1.0

{"id":"evt_01J8ZKC6Q0R4T7V9X1Y3Z5A7B9","type":"job.printed","created_at":"2026-09-15T10:22:58.441Z","account_id":"acct_4021","principal_account_id":"acct_4000","job_id":"job_3000000913","computer_id":"cmp_1000042","correlation_id":null,"data":{}}

The signed message is the UTF-8 header text and raw body joined exactly as webhook-id + "." + webhook-timestamp + "." + raw_body. The signature value is v1, followed by standard base64 of the HMAC-SHA256 digest keyed with the secret's UTF-8 bytes. The signature header can contain several space-separated v1, values during rotation; accept the delivery if any one matches.

Verify the timestamp and exact raw bytes

Do not parse and re-serialize JSON before verification. Even an equivalent object can produce different bytes and a different signature. Capture the body before middleware changes it. Reject a timestamp outside your tolerance to limit replay, and use a constant-time comparison.

Python verifier
import base64, hashlib, hmac, time

def verify_webhook(secret: str, webhook_id: str, webhook_timestamp: str,
                   webhook_signature: str, raw_body: bytes,
                   tolerance_sec: int = 300) -> bool:
    if not secret or not all(isinstance(value, str) and value for value in (webhook_id, webhook_timestamp, webhook_signature)):
        return False
    try:
        timestamp = int(webhook_timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance_sec:
        return False
    signed = f"{webhook_id}.{webhook_timestamp}.".encode("utf-8") + raw_body
    digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(digest).decode("ascii")
    candidates = webhook_signature.split()
    return any(hmac.compare_digest(candidate.encode("utf-8"), expected.encode("ascii")) for candidate in candidates)

# Call this from your route with its request object and stored secret.
def receive_printhook(request, webhook_secret: str):
    raw_body = request.get_data(cache=True, as_text=False)
    valid = verify_webhook(
        secret=webhook_secret,
        webhook_id=request.headers.get("webhook-id", ""),
        webhook_timestamp=request.headers.get("webhook-timestamp", ""),
        webhook_signature=request.headers.get("webhook-signature", ""),
        raw_body=raw_body,
    )
    if not valid:
        return ("invalid signature", 400)
    event = request.get_json(silent=True)
    if not isinstance(event, dict) or event.get("id") != request.headers.get("webhook-id"):
        return ("invalid event", 400)
    enqueue_once(event["id"], event)  # Must durably commit or raise; never swallow storage errors.
    return ("", 204)

The handler uses the common request.get_data interface; adapt only the raw-body, queue, and response calls to your web framework. Set a request-body size limit in your framework. The enqueue_once function is your application-specific durable inbox insert, not an included queue implementation. It must atomically persist the event and work, treat an existing id as accepted, and raise on storage failure so the handler does not acknowledge lost work. Never log the webhook secret or include it in a response.

Acknowledge quickly and process idempotently

Return any 2xx response after verification and durable acceptance. Receiver response bodies are ignored. If business work can take time, insert the verified event into your own durable queue or database transaction, then acknowledge.

  • At-least-once: acceptance followed by a lost response can produce another delivery. Put a unique constraint on webhook-id or the event body's id.
  • Unordered: do not infer current job state from arrival order. Fetch GET /v1/jobs/{id} when the latest representation matters.
  • One event per request: validate that the body id matches webhook-id after signature verification, then store the full event object.
  • Failures: there is one immediate attempt and one retry about five seconds after a failure. Targets are independent.
idempotent insert sketch
BEGIN;
INSERT INTO printhook_events (event_id, event_type, raw_json, received_at)
VALUES (:webhook_id, :event_type, :raw_body, CURRENT_TIMESTAMP)
ON CONFLICT (event_id) DO NOTHING;
COMMIT;

Reconcile gaps and replay failed attempts

Treat GET /v1/events as the ordered authority. Save the event created_at and id from your last reconciliation, page forward with the opaque cursor, and apply the same idempotent event insert used by the receiver. The API documents a small event visibility lag in the limits.event_visibility_lag_sec field of GET /v1/meta; overlap reconciliation windows and rely on the event-id uniqueness rule.

Inspect durable attempts through GET /v1/webhook-deliveries?webhook_id=whk_312. An operator can call POST /v1/webhook-deliveries/{id}/replay when the attempt is eligible and its payload is still retained. Replay uses the original event bytes with the subscription's current URL and secret, so it remains a duplicate-safe operation for your receiver.

curl --connect-timeout 5 --max-time 20 --fail-with-body --silent --show-error \
  "https://api.printhook.io/v1/events?type=job.*&created_after=2026-09-15T10:00:00Z&limit=200&order=asc" \
  --header @"$PRINTHOOK_AUTH_HEADER_FILE" \
  -H "Accept: application/json"

For the job-creation side of the workflow, see Reliable jobs.

Pair push delivery with reconciliation.