Webhooks
Fulkruma posts JSON event notifications to URLs you register. Use them to mirror state into your own systems — fulfilment dashboards, CRMs, accounting — without polling.
This page covers registering and managing endpoints, what a delivery looks like and how to verify it, retries, and the delivery log. Per-event payload pages live under /docs/api/webhooks/events/<event.type>.
All management requests must be signed — see Authentication.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/webhooks/endpoints |
List registered endpoints |
POST |
/api/v1/webhooks/endpoints |
Register an endpoint |
PATCH |
/api/v1/webhooks/endpoints/:id |
Update, pause or re-enable an endpoint |
DELETE |
/api/v1/webhooks/endpoints/:id |
Delete an endpoint |
GET |
/api/v1/webhooks/events |
List deliveries (the delivery log), with every attempt |
GET |
/api/v1/webhooks/events/:id |
Get one delivery |
POST |
/api/v1/webhooks/events/:id/retry |
Retry a delivery now |
Register an endpoint
POST /api/v1/webhooks/endpoints
Registers a URL to receive events and returns the signing secret in plaintext — once, on this response only. Store it in your secrets manager and use it to verify the Fulkruma-Signature header on every delivery.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
url |
string (URL) | yes | Where Fulkruma POSTs. Must be https://, and must not point at a private, loopback or link-local address — see Allowed URLs. |
events |
string[] | no | What to receive. Default ["*"] (every event). Each entry is "*", an event type ("fulkruma.shipment.created.v1"), or a prefix ending in * ("fulkruma.shipment.*" — every shipment event). Anything else (e.g. "shipment.created") is a 400. |
description |
string | no | Human label for the dashboard. |
Response — 201 Created
{
"data": {
"endpoint": {
"id": "clx8n4p2q0001rw9v5m7t3k1z",
"accountId": "acc_01HX...",
"url": "https://your-app.com/webhooks/fulkruma",
"events": ["fulkruma.shipment.*"],
"description": "Production fulfilment listener",
"active": true,
"consecutiveFailures": 0,
"failingSince": null,
"disabledAt": null,
"disabledReason": null,
"createdAt": "2026-05-12T10:42:00.123Z",
"updatedAt": "2026-05-12T10:42:00.123Z"
},
"secret": "whsec_AbCdEf1234567890XyZaBcDeF1234567890aBcDeF1234"
},
"error": null,
"meta": { ... }
}
The top-level secret is present only on this 201 response — later reads return only secretPreview (whsec_…last4).
Capture the secret immediately. Pipe it to your secrets manager. To rotate, delete the endpoint and re-register; secret rotation in place is not yet supported.
fulkruma_curl POST '/api/v1/webhooks/endpoints' \
'{"url":"https://your-app.com/webhooks/fulkruma","events":["fulkruma.shipment.*"]}'
Errors: 400 VALIDATION — a malformed body, an unknown events format, or a URL Fulkruma refuses (the message says why, e.g. url: blocked: hooks.internal.example resolves to a private, loopback or link-local address (10.0.3.7)).
List endpoints
GET /api/v1/webhooks/endpoints
Returns every endpoint in the workspace, newest first. The plaintext secret is never included; secretPreview is. Each endpoint also carries its delivery health:
| Field | Description |
|---|---|
active |
false when paused by you or switched off by Fulkruma. |
consecutiveFailures |
Failed attempts in a row since the last 2xx. |
failingSince |
When the current run of failures started; null while healthy. |
disabledAt, disabledReason |
Set when Fulkruma switched the endpoint off because it kept failing (see Retries and failures); null for a manual pause. |
Update an endpoint
PATCH /api/v1/webhooks/endpoints/:id
Partial update of url, events, description, active.
active: falsepauses delivery without losing the endpoint. Deliveries still queued for it becomefailed, and events that happen while it is paused are not queued for it.active: truere-enables it — also after Fulkruma switched it off — and resetsconsecutiveFailures,failingSince,disabledAtanddisabledReason. Retry the deliveries you missed with Retry a delivery.- A new
urlgoes through the same checks as on register. The signing secret stays the same.
Delete an endpoint
DELETE /api/v1/webhooks/endpoints/:id
Hard-deletes the endpoint and its delivery log, and stops further deliveries.
List deliveries
GET /api/v1/webhooks/events
The delivery log, newest first: one row per event per endpoint, with every attempt made at it. Useful for debugging "did the event fire, and what did my server answer?" without trawling your own logs.
Query parameters
| Param | Type | Description |
|---|---|---|
limit |
integer | 1–200, default 50. |
cursor |
string | The nextCursor of the previous page. |
type |
string | Only this event type, e.g. fulkruma.shipment.created.v1. |
status |
pending | sent | failed |
Only deliveries in this state. |
endpointId |
string | Only deliveries to this endpoint. |
{
"data": {
"events": [
{
"id": "clx9q2r7s0004rw9v1c8d2e3f",
"accountId": "acc_01HX...",
"endpointId": "clx8n4p2q0001rw9v5m7t3k1z",
"eventId": "evt_01HXAB7K3M9N2P5QRS8TVWXY3Z",
"type": "fulkruma.shipment.created.v1",
"payload": { "id": "evt_01HXAB7K3M9N2P5QRS8TVWXY3Z", "type": "fulkruma.shipment.created.v1", "...": "..." },
"status": "pending",
"attempts": 2,
"lastAttemptAt": "2026-05-12T10:43:01.500Z",
"nextRetryAt": "2026-05-12T10:48:01.500Z",
"responseCode": 503,
"responseBody": "upstream unavailable",
"lastError": "HTTP 503",
"durationMs": 87,
"deliveredAt": null,
"createdAt": "2026-05-12T10:42:00.200Z",
"updatedAt": "2026-05-12T10:43:01.500Z",
"deliveryAttempts": [
{ "attemptNumber": 1, "status": "failed", "responseCode": 503, "durationMs": 91, "error": "HTTP 503", "nextRetryAt": "2026-05-12T10:43:01.300Z", "attemptedAt": "2026-05-12T10:42:01.300Z", "...": "..." },
{ "attemptNumber": 2, "status": "failed", "responseCode": 503, "durationMs": 87, "error": "HTTP 503", "nextRetryAt": "2026-05-12T10:48:01.500Z", "attemptedAt": "2026-05-12T10:43:01.500Z", "...": "..." }
]
}
],
"nextCursor": "clx9q2r7s0004rw9v1c8d2e3f"
},
"error": null,
"meta": { ... }
}
| Field | Description |
|---|---|
eventId |
The event's evt_… id — the id in the body you received. The same on every attempt. |
payload |
The envelope sent (see What a delivery looks like). |
status |
pending — queued: not attempted yet, or a retry is scheduled at nextRetryAt. sent — your endpoint answered 2xx. failed — given up: every attempt failed, or the endpoint was paused / switched off. |
attempts |
How many attempts were made. |
responseCode, responseBody |
Your server's last status code and the first 2 KiB of its last response. responseCode is null when no response came back. |
lastError |
Why the last attempt failed: HTTP 503, timed out after 10000ms, connect ECONNREFUSED …, redirect not followed (HTTP 302), blocked: …, endpoint is disabled. |
durationMs |
How long the last attempt took. |
deliveredAt |
When it was answered with 2xx. |
deliveryAttempts |
Every attempt, oldest first: attemptNumber, status (succeeded / failed), responseCode, durationMs, error, nextRetryAt (the retry that failure scheduled; null on success or give-up), attemptedAt. |
nextCursor |
Pass as cursor for the next page; null on the last. |
fulkruma_curl GET '/api/v1/webhooks/events?status=failed&limit=20'
Get a delivery
GET /api/v1/webhooks/events/:id
One row of the log ({ "event": { ... } }), with every attempt. 404 for an id outside your workspace.
Retry a delivery
POST /api/v1/webhooks/events/:id/retry
Queues one more attempt now — for a failed delivery once your handler is fixed, or to send a sent one again. Responds 202 Accepted with the row in pending; the attempt goes out within a few seconds, so read it back with Get a delivery. If that attempt fails too, any scheduled retries it has left follow; a delivery that already used all six attempts gets just this one.
Errors: 404 NOT_FOUND; 409 ALREADY_QUEUED (it is pending); 409 ENDPOINT_DISABLED (re-enable the endpoint first).
What a delivery looks like
POST https://your-app.com/webhooks/fulkruma
Content-Type: application/json
User-Agent: Fulkruma-Webhooks/1.0
Fulkruma-Signature: t=1715526783,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Fulkruma-Event-Id: evt_01HXAB7K3M9N2P5QRS8TVWXY3Z
Fulkruma-Event-Type: fulkruma.shipment.created.v1
Fulkruma-Delivery-Id: clx9q2r7s0004rw9v1c8d2e3f
Fulkruma-Delivery-Attempt: 1
{"id":"evt_01HXAB7K3M9N2P5QRS8TVWXY3Z","type":"fulkruma.shipment.created.v1","occurredAt":"2026-05-12T10:42:00.123Z","accountId":"acc_01HX...","data":{ ... },"metadata":{}}
| Header | Description |
|---|---|
Fulkruma-Signature |
t=<unix seconds>,v1=<hex> — see Signature verification. |
Fulkruma-Event-Id |
The event id (also the body's id). |
Fulkruma-Event-Type |
The event type (also the body's type). |
Fulkruma-Delivery-Id |
The row in the delivery log. |
Fulkruma-Delivery-Attempt |
1 for the first attempt, 2+ for retries. |
Event envelope
Every event uses the same outer envelope, with the resource-specific payload inside data:
| Field | Type | Description |
|---|---|---|
id |
string (evt_…) |
Unique event ID. The same on every retry, and for every endpoint that receives it. Use it as your idempotency key. |
type |
string | The event type, e.g. fulkruma.shipment.created.v1. The version suffix lets Fulkruma evolve payloads safely. |
occurredAt |
string (ISO 8601 UTC) | When the underlying state change happened. |
accountId |
string | The workspace the event belongs to. |
data |
object | Resource-specific payload. See the per-event pages. |
metadata |
object | Reserved for future use (correlation IDs, partner-routing hints). Currently {}. |
Who receives an event
An event goes to every endpoint that is, at the moment it is sent out:
- in the same workspace as the event — never to another workspace's endpoints;
- active (not paused, not switched off);
- subscribed to it:
"*", its exact type, or a matching prefix ("fulkruma.shipment.*"); - registered before the event happened — a new endpoint does not receive earlier events.
The first attempt goes out within a couple of seconds of the state change committing.
Signature verification
Every delivery carries:
Fulkruma-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
where v1 = HMAC-SHA256(secret, "<t>.<raw request body>"), hex-encoded, keyed with the endpoint's whsec_… secret as UTF-8. To verify:
- Read
tandv1from the header (split on,, then on the first=). - Reject the request if
tis more than 300 seconds from your clock (replay protection). - Compute
HMAC-SHA256(secret, t + "." + rawBody)over the raw bytes you received — not the body re-serialised from parsed JSON, which changes whitespace and escaping. - Constant-time-compare it with
v1.
The SDKs do all of this: verifyWebhook (Node), verify_webhook (Python), VerifyWebhook (Go). By hand:
// Node — rawBody is the Buffer from express.raw({ type: 'application/json' })
import crypto from 'node:crypto';
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((kv) => {
const i = kv.indexOf('=');
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const { t, v1 } = parts;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
return expected.length === v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
# Python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Retries and failures
Success is any 2xx response within 10 seconds. Anything else is a failed attempt: another status, a timeout, a refused or reset connection, a TLS error, or a redirect — redirects are never followed, so point the endpoint at the final URL.
Retries follow a fixed schedule, each measured from the attempt before it:
| Attempt | When |
|---|---|
| 1 | Within a couple of seconds of the event |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes later |
| 4 | 25 minutes later |
| 5 | 2 hours later |
| 6 | 12 hours later |
After the sixth failure (about 14½ hours in) the delivery is failed and stays so until you retry it.
- At-least-once. A timeout can fail an attempt your server actually processed, so the same event can arrive more than once. Dedupe on the body's
id(orFulkruma-Event-Id) — aprocessed_eventstable with a unique constraint is the canonical guard. - Acknowledge fast. If your handler needs longer than a few seconds, queue the work and return
200immediately. - Endpoints that keep failing are switched off. When an endpoint has failed 20 attempts in a row and has been failing for at least 24 hours, Fulkruma sets
active: falsewithdisabledAtanddisabledReason, marks its queued deliveriesfailed, and records awebhook.auto_disabledentry in the audit log. Both conditions must hold, so a short outage — a deploy, a few minutes of errors while events are busy — never switches anyone off; the retries ride it out. Fix the receiver, re-enable the endpoint (PATCH … {"active": true}), then retry what you missed (GET /webhooks/events?endpointId=…&status=failed). The switch-off also raisesfulkruma.webhook_endpoint.disabled.v1, delivered to your other endpoints that subscribe to it — so point a second endpoint (or an alerting service) at it — or watchactiveon List endpoints or the audit log.
Allowed URLs
Fulkruma makes the request from its own servers, so it refuses targets on private networks:
https://only. (http://is accepted only by a local development server.)- The host may not be
localhostor end in.localhost,.localor.internal, and every address it resolves to must be public: loopback (127.0.0.0/8,::1), private (10/8,172.16/12,192.168/16,fc00::/7), carrier-grade NAT (100.64/10), link-local (169.254/16— including cloud metadata — andfe80::/10), unspecified, multicast, reserved and documentation ranges are refused, as are IPv4-mapped forms of those. - The check runs when you register or change the URL and again on every delivery, against the address the connection actually uses — a hostname that later resolves somewhere private fails that attempt with
blocked: …inlastError.
To receive webhooks on your own machine during development, expose it through a public HTTPS tunnel (ngrok, Cloudflare Tunnel, …) and register the tunnel's URL.
Ordering
Not guaranteed. Deliveries run concurrently, and a retried delivery arrives after events that happened later. Use occurredAt and the resource's own state (GET it) when order matters — e.g. ignore a fulkruma.shipment.status_updated.v1 whose occurredAt is older than the last one you applied.
Event catalog
Emitted today:
| Event type | When | Page |
|---|---|---|
fulkruma.product.created.v1 |
A product is created. | → |
fulkruma.product.updated.v1 |
A product's fields change. | → |
fulkruma.product.archived.v1 |
A product is archived. | → |
fulkruma.variant.created.v1 |
A variant is added to a product. | → |
fulkruma.variant.archived.v1 |
A variant is archived. | → |
fulkruma.warehouse.created.v1 |
A warehouse is created. | → |
fulkruma.stock.adjusted.v1 |
A stock level changes. | → |
fulkruma.stock.low.v1 |
A stock level falls below its variant's lowStockThreshold (once per crossing). |
→ |
fulkruma.shipment.created.v1 |
A shipment (draft) is created. | → |
fulkruma.shipment.pickup_confirmed.v1 |
A draft is confirmed and booked with the courier. | → |
fulkruma.shipment.status_updated.v1 |
The courier reports a new status (picked up, in transit, delivered, returned, …). | → |
fulkruma.shipment.cancelled.v1 |
A shipment is cancelled (shipping credit refunded when it was charged). | → |
fulkruma.shipment.rebooked.v1 |
A dead shipment is rebooked as a new one. | → |
fulkruma.delivery.created.v1 |
A digital-delivery grant is issued. | → |
fulkruma.delivery.updated.v1 |
A delivery is extended, its downloads reset, or revoked. | → |
fulkruma.delivery.downloaded.v1 |
A download of a delivery is recorded. | → |
fulkruma.delivery.expired.v1 |
A delivery's download window closed (expired or revoked). | → |
fulkruma.license.issued.v1 |
A license key is issued. | → |
fulkruma.license.revoked.v1 |
A license key is revoked. | → |
fulkruma.license.activated.v1 |
A license key is bound to a new instance. | → |
fulkruma.license.deactivated.v1 |
An instance is unbound from a license key. | → |
fulkruma.webhook_endpoint.disabled.v1 |
Fulkruma switches off one of your endpoints that kept failing (sent to your other endpoints). | → |
A delivered, returned or failed parcel arrives as fulkruma.shipment.status_updated.v1 with that status — there is no separate delivered event.
Next
- Per-event pages under
/docs/api/webhooks/events/. - Webhooks reference — every route's parameters, generated from the code.
- Authentication — how to sign management requests (a different scheme from the webhook signature).