Webhooks

Webhooks let Fulkruma push event notifications to your server in real time, so you don't have to poll. Use them to know when a shipment moves through carrier states, when a stock movement was logged, when a license was issued or revoked. The Python SDK wraps seven endpoints behind fulkruma.webhooks. For HTTP shapes, see API → Webhooks; for the per-event payload schemas, see Webhook events.

Namespace

fulkruma.webhooks     # WebhooksResources

Seven methods. Four manage delivery endpoints (create_endpoint, list_endpoints, update_endpoint, delete_endpoint); three read and act on the delivery log — what Fulkruma sent, what your server answered, every retry (list_events, get_event, retry_event).

Methods

create_endpoint

fulkruma.webhooks.create_endpoint(body: dict, *, on_behalf_of: str | None = None) -> dict

Registers a URL to receive event deliveries. events narrows the types (["*"], every event, when omitted); patterns like "fulkruma.shipment.*" are allowed. The result is {"endpoint": {...}, "secret": "whsec_..."} — this is the only call that returns the secret. The SDK auto-mints an idempotency key.

result = fulkruma.webhooks.create_endpoint({
    "url": "https://your-app.example.com/webhooks/fulkruma",
    "events": ["fulkruma.shipment.*", "fulkruma.license.issued.v1"],
    "description": "Production receiver",
})

print(result["endpoint"]["id"])
secret = result["secret"]   # STASH NOW

The signing secret appears once. Just like API keys, the webhook signing secret is only returned on create. You'll use it to verify the Fulkruma-Signature header on every inbound delivery (see Verify inbound deliveries below). Store it before the function returns.

list_endpoints

fulkruma.webhooks.list_endpoints(*, on_behalf_of: str | None = None) -> dict

Returns every endpoint in the workspace, newest first. The secret is not included — only a secretPreview (whsec_… plus its last 4 characters); create_endpoint alone returns the secret.

result = fulkruma.webhooks.list_endpoints()
for e in result["endpoints"]:
    state = "active" if e.get("active") else "paused"
    print(e["id"], e["url"], state)

update_endpoint

fulkruma.webhooks.update_endpoint(
    endpoint_id: str,
    patch: dict,
    *,
    on_behalf_of: str | None = None,
) -> dict

PATCH semantics. Pass {"active": False} to pause delivery without deleting the endpoint: deliveries still queued for it become failed, and events raised while it is paused are not queued for it. {"active": True} re-enables it — also after Fulkruma switched it off for failing — and clears its failure streak (consecutiveFailures, failingSince, disabledAt, disabledReason). A new url is checked like on create (https, no private addresses); the secret stays the same.

fulkruma.webhooks.update_endpoint("whe_01HX...", {"active": False})
# ... maintenance ...
fulkruma.webhooks.update_endpoint("whe_01HX...", {"active": True})

You can also rewrite the URL or the events list:

fulkruma.webhooks.update_endpoint("whe_01HX...", {
    "url": "https://new-app.example.com/webhooks/fulkruma",
    "events": ["fulkruma.shipment.status_updated.v1", "fulkruma.shipment.cancelled.v1"],
})

delete_endpoint

fulkruma.webhooks.delete_endpoint(
    endpoint_id: str,
    *,
    on_behalf_of: str | None = None,
) -> dict

Hard-deletes the endpoint and its delivery log. A request already in flight may still arrive; nothing new is queued for it.

fulkruma.webhooks.delete_endpoint("whe_01HX...")

list_events

fulkruma.webhooks.list_events(
    *,
    limit: int | None = None,          # 1-200, default 50
    cursor: str | None = None,         # the previous page's nextCursor
    type: str | None = None,           # e.g. "fulkruma.shipment.created.v1"
    status: str | None = None,         # "pending" | "sent" | "failed"
    endpoint_id: str | None = None,
    on_behalf_of: str | None = None,
) -> dict

The delivery log, newest first: {"events": [...], "nextCursor": str | None} — one row per event per endpoint, with its status (pending: queued or waiting for a retry at nextRetryAt; sent: your endpoint answered 2xx; failed: given up), attempts, the last responseCode / lastError, and every attempt made (deliveryAttempts).

cursor = None
while True:
    page = fulkruma.webhooks.list_events(status="failed", limit=100, cursor=cursor)
    for d in page["events"]:
        print(d["eventId"], d["type"], d["lastError"])
    cursor = page["nextCursor"]
    if not cursor:
        break

get_event

fulkruma.webhooks.get_event(delivery_id: str, *, on_behalf_of: str | None = None) -> dict

One delivery with every attempt made at it: {"event": {...}}.

retry_event

fulkruma.webhooks.retry_event(delivery_id: str, *, on_behalf_of: str | None = None) -> dict

Queues one more attempt now — a failed delivery once your handler is fixed, or a sent one to send again. The server answers 202 with the row in pending; the attempt goes out within seconds, so read it back with get_event. Raises FulkrumaError with status 409 (ALREADY_QUEUED, ENDPOINT_DISABLED) when the delivery is already queued or its endpoint is off.

Types

# endpoint
{
    "id": "...",
    "accountId": "...",
    "url": "https://...",
    "events": ["*"],                    # ["*"] means every event
    "description": "..." | None,
    "active": bool,
    "consecutiveFailures": 0,           # failed attempts in a row since the last 2xx
    "failingSince": "..." | None,       # start of the current failure streak
    "disabledAt": "..." | None,         # set when Fulkruma switched it off for failing
    "disabledReason": "..." | None,
    "secretPreview": "whsec_…abcd",     # list only
    "createdAt": "...",
    "updatedAt": "...",
}

# delivery (list_events / get_event / retry_event)
{
    "id": "...",
    "accountId": "...",
    "endpointId": "...",
    "eventId": "evt_...",               # the envelope's id, the same on every attempt
    "type": "fulkruma.shipment.status_updated.v1",
    "payload": {...},                   # the event envelope sent
    "status": "pending" | "sent" | "failed",
    "attempts": 1,
    "lastAttemptAt": "..." | None,
    "nextRetryAt": "..." | None,
    "responseCode": 200 | None,         # None when no response came back
    "responseBody": "..." | None,       # first 2 KiB of the last response
    "lastError": "HTTP 503" | None,     # or "timed out after 10000ms", "blocked: ..."
    "durationMs": 87 | None,
    "deliveredAt": "..." | None,
    "createdAt": "...",
    "updatedAt": "...",
    "deliveryAttempts": [               # oldest first
        {"attemptNumber": 1, "status": "succeeded" | "failed", "responseCode": 200 | None,
         "durationMs": 87, "error": None, "nextRetryAt": None, "attemptedAt": "...", ...},
    ],
}

For the full event-type catalog and per-type payload schemas, see Webhook events.

Common patterns

Register at deploy time. If you provision endpoints via IaC, run create + stash the secret atomically:

def ensure_endpoint(fulkruma, url: str, events: list, secret_manager) -> dict:
    result = fulkruma.webhooks.list_endpoints()
    existing = next((e for e in result["endpoints"] if e["url"] == url), None)
    if existing:
        return existing
    created = fulkruma.webhooks.create_endpoint({"url": url, "events": events})
    endpoint = created["endpoint"]
    secret_manager.put(f"FULKRUMA_WEBHOOK_SECRET/{endpoint['id']}", created["secret"])
    return endpoint

Verify inbound deliveries. The SDK ships a verify_webhook helper. It checks the Fulkruma-Signature: t=<unix>,v1=<hex> header — an HMAC-SHA256 of <t>.<raw body> with the endpoint's secret — and rejects a timestamp more than 5 minutes off:

import os
from flask import Flask, request, abort
from fulkruma import verify_webhook, FulkrumaError

app = Flask(__name__)

@app.post("/webhooks/fulkruma")
def fulkruma_webhook():
    try:
        event = verify_webhook(
            raw_body=request.get_data(),
            signature=request.headers.get("Fulkruma-Signature"),
            secret=os.environ["FULKRUMA_WEBHOOK_SECRET"],
        )
    except FulkrumaError:
        abort(400)
    # event is the typed delivery; handle by type
    return "", 200

Riding out a deploy, and catching up afterwards. You don't need to pause an endpoint for a deploy: a failed delivery is retried 1 min, 5 min, 25 min, 2 h and 12 h later, so a receiver that is down for a while still gets everything. Pausing ({"active": False}) stops deliveries altogether — events raised while paused are not queued for that endpoint. If deliveries did give up (or Fulkruma switched the endpoint off after it kept failing), re-enable it and retry what failed:

fulkruma.webhooks.update_endpoint("whe_01HX...", {"active": True})
cursor = None
while True:
    page = fulkruma.webhooks.list_events(endpoint_id="whe_01HX...", status="failed", cursor=cursor)
    for d in page["events"]:
        fulkruma.webhooks.retry_event(d["id"])
    cursor = page["nextCursor"]
    if not cursor:
        break

Per-environment endpoints. Provision separate endpoints per environment so prod events never hit staging:

fulkruma.webhooks.create_endpoint({
    "url": "https://staging.your-app.example.com/webhooks/fulkruma",
    "description": "staging",
})
fulkruma.webhooks.create_endpoint({
    "url": "https://prod.your-app.example.com/webhooks/fulkruma",
    "description": "prod",
})

Errors

err.status err.code Cause
400 VALIDATION url isn't a URL Fulkruma will call (not https, or a private / loopback / link-local address — the message says which), or events is empty or holds something other than "*", an event type or a "fulkruma.….*" prefix.
403 NO_ACCOUNT The credentials resolve to no workspace.
404 NOT_FOUND No endpoint (update / delete) or delivery (get_event / retry_event) with that ID in this workspace.
409 ALREADY_QUEUED retry_event on a delivery that is already pending.
409 ENDPOINT_DISABLED retry_event while the delivery's endpoint is paused or switched off.

Next