Sign in Get started

Webhooks

Register HTTPS endpoints that receive signed, retried event deliveries (such as workflow_run.completed). Verify the HMAC signature, inspect the delivery log, and replay failed deliveries.

View as Markdown

Webhooks push events to a URL you own instead of making you poll. Register an HTTPS endpoint and Pictograph POSTs a signed JSON body to it whenever a subscribed event fires (for example, when a workflow run finishes). Deliveries are durable and retried with backoff, and every attempt is recorded in a delivery log you can inspect and replay.

Every example below shows the Python SDK call and the equivalent raw REST request. The REST examples authenticate with an X-API-Key header; set PICTOGRAPH_API_KEY in your shell to copy-and-run them.

from pictograph import Client
client = Client()  # reads PICTOGRAPH_API_KEY

Why webhooks

One outbound HTTP sink replaces a pile of polling loops. You wire the last mile (your queue, your data warehouse, Slack, a serverless function) and Pictograph handles signing, retries, and the delivery log. The endpoint must be public HTTPS: private, loopback, and cloud-metadata addresses are rejected at registration time to prevent SSRF.

Event types

EventFires when
workflow_run.completedA workflow run finishes successfully. The payload carries the run id and signed artifact URLs.
workflow_run.failedA workflow run ends in error.

An endpoint subscribes to one or more event types (default ["workflow_run.completed"]). Management calls (create, delete, test, replay) require an admin/owner API key.

create

Register an endpoint. The response carries the signing secret exactly once. Store it securely: it is never retrievable again (rotate to mint a new one).

created = client.webhooks.create(
    "https://example.com/hooks/pictograph",
    description="Prod ingestion",
    event_types=["workflow_run.completed", "workflow_run.failed"],
)
print(created.endpoint.id)
print(created.secret)   # whsec_… shown once
curl -s -X POST "https://api.pictograph.io/api/v1/developer/webhooks/endpoints" \
  -H "X-API-Key: $PICTOGRAPH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/pictograph", "description": "Prod ingestion", "event_types": ["workflow_run.completed", "workflow_run.failed"]}'

Returns CreatedWebhookEndpoint (an endpoint plus the one-time secret).

list

List every webhook endpoint in your organization. No secret material is ever returned, only a display secret_prefix.

for ep in client.webhooks.list():
    print(ep.id, ep.url, ep.enabled, ep.event_types)
curl -s "https://api.pictograph.io/api/v1/developer/webhooks/endpoints" \
  -H "X-API-Key: $PICTOGRAPH_API_KEY"

Returns WebhookEndpoint objects.

FieldMeaning
idEndpoint UUID.
urlThe HTTPS destination.
event_typesSubscribed event types.
enabledWhether deliveries are sent. Auto-disabled after repeated failures.
secret_versionCurrent signing-secret version (bumped on rotate).
secret_prefixFirst characters of the signing secret, for display only.
consecutive_failuresFailed deliveries in a row. Resets to 0 on any success.
disabled_reasonSet when the endpoint was auto-disabled.
last_delivery_atTimestamp of the most recent delivery attempt.

get

Fetch a single endpoint by UUID.

ep = client.webhooks.get("wh-uuid")
print(ep.url, ep.enabled)
curl -s "https://api.pictograph.io/api/v1/developer/webhooks/endpoints/wh-uuid" \
  -H "X-API-Key: $PICTOGRAPH_API_KEY"

Returns a WebhookEndpoint.

test

Send a synthetic, signed webhook.test event and get the immediate result. The attempt is recorded in the delivery log.

result = client.webhooks.test("wh-uuid")
print(result["delivered"], result["status_code"])
curl -s -X POST "https://api.pictograph.io/api/v1/developer/webhooks/endpoints/wh-uuid/test" \
  -H "X-API-Key: $PICTOGRAPH_API_KEY"

deliveries

Inspect the delivery log, optionally filtered by endpoint and status (pending, delivered, failed, dead_letter).

for d in client.webhooks.deliveries(status="failed", limit=20):
    print(d.id, d.event_type, d.attempts, d.last_status_code, d.last_error)
curl -s "https://api.pictograph.io/api/v1/developer/webhooks/deliveries?status=failed&limit=20" \
  -H "X-API-Key: $PICTOGRAPH_API_KEY"

Returns WebhookDelivery objects.

FieldMeaning
idDelivery row UUID (use with replay).
delivery_idIdempotency token sent to your endpoint as X-Pictograph-Delivery-Id.
event_typeThe event delivered.
statuspending, delivered, failed, or dead_letter.
attemptsAttempts made so far.
last_status_codeHTTP status of the last attempt.
last_errorError from the last failed attempt.
next_retry_atWhen the next attempt is due (while pending).
delivered_atWhen delivery first succeeded.

replay

Re-queue a failed or dead-letter delivery with a fresh retry budget.

client.webhooks.replay("delivery-uuid")
curl -s -X POST "https://api.pictograph.io/api/v1/developer/webhooks/deliveries/delivery-uuid/replay" \
  -H "X-API-Key: $PICTOGRAPH_API_KEY"

delete

Delete an endpoint and its delivery history.

client.webhooks.delete("wh-uuid")
curl -s -X DELETE "https://api.pictograph.io/api/v1/developer/webhooks/endpoints/wh-uuid" \
  -H "X-API-Key: $PICTOGRAPH_API_KEY"

Verifying a delivery

Every delivery carries an X-Pictograph-Signature header shaped like t=<unix_ts>,v1=<hex> (the same scheme Stripe uses). Compute HMAC-SHA256 over "{timestamp}.{raw_body}" with your signing secret and compare in constant time. Reject deliveries whose timestamp is older than five minutes to close the replay window. During a secret rotation the header carries multiple v1= values (current and previous), so accept the delivery if any one matches.

import hashlib
import hmac
import time


def verify(secret: str, signature_header: str, raw_body: bytes, *, tolerance: int = 300) -> bool:
    timestamp: str | None = None
    signatures: list[str] = []
    for part in signature_header.split(","):
        key, _, value = part.partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False  # stale: possible replay
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return any(hmac.compare_digest(sig, expected) for sig in signatures)

Other headers on each delivery:

HeaderMeaning
X-Pictograph-Signaturet=<ts>,v1=<hex> (one or more v1 during rotation).
X-Pictograph-Delivery-IdStable id for the delivery. Use it to dedupe on your side (deliveries are at-least-once).
X-Pictograph-Event-TypeThe event type, for routing.
X-Pictograph-TimestampThe signed timestamp, mirrored for convenience.

Delivery, retries, and auto-disable

Delivery is at-least-once. A non-2xx response or a connection failure is retried on a backoff schedule (roughly 30s, 2m, 10m, 1h, 6h). After the budget is exhausted the delivery is marked dead_letter rather than dropped, and you can replay it once the endpoint is healthy. An endpoint that fails many deliveries in a row is auto-disabled; re-enable it from Settings to resume.

Always return a 2xx quickly and do heavy processing asynchronously: a slow handler looks like a failure and triggers retries.

Copied to clipboard