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. This page covers the fulkruma.webhooks namespace — the control plane for endpoint management. For HTTP fields, see API: Webhooks; for the per-event payload schemas, see Webhook events.

Namespace

fulkruma.webhooks — every method:

fulkruma.webhooks.listEndpoints()
fulkruma.webhooks.createEndpoint(input)
fulkruma.webhooks.updateEndpoint(id, patch)
fulkruma.webhooks.deleteEndpoint(id)
fulkruma.webhooks.listEvents(params?)
fulkruma.webhooks.getEvent(id)
fulkruma.webhooks.retryEvent(id)

Seven methods. Four manage delivery endpoints; three read and act on the delivery log — what Fulkruma sent, what your server answered, every retry.

Methods

webhooks.createEndpoint

Signature. fulkruma.webhooks.createEndpoint(input: { url: string; events?: string[]; description?: string }): Promise<{ endpoint: Record<string, unknown>; secret: string }>

Registers a URL to receive event deliveries. events narrows the types (["*"], every event, when omitted); patterns like "fulkruma.shipment.*" are allowed. The response carries the endpoint's signing secret next to it — this is the only call that returns it. The SDK auto-mints an Idempotency-Key.

const { endpoint, secret } = await fulkruma.webhooks.createEndpoint({
  url: 'https://your-app.example.com/webhooks/fulkruma',
  events: ['fulkruma.shipment.*', 'fulkruma.license.issued.v1'],
  description: 'Production receiver',
});

console.log(endpoint.id);  // STASH `secret` NOW (whsec_...)

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). Store it before the function returns.

webhooks.listEndpoints

Signature. fulkruma.webhooks.listEndpoints(): Promise<{ endpoints: Array<Record<string, unknown>> }>

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

const { endpoints } = await fulkruma.webhooks.listEndpoints();
for (const e of endpoints as Array<{ id: string; url: string; active: boolean }>) {
  console.log(e.id, e.url, e.active ? 'active' : 'paused');
}

webhooks.updateEndpoint

Signature. fulkruma.webhooks.updateEndpoint(id, patch): Promise<{ endpoint: Record<string, unknown> }>

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.

await fulkruma.webhooks.updateEndpoint('whe_01HX...', { active: false });
// ... maintenance ...
await fulkruma.webhooks.updateEndpoint('whe_01HX...', { active: true });

You can also rewrite the URL or the events list:

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

webhooks.deleteEndpoint

Signature. fulkruma.webhooks.deleteEndpoint(id): Promise<{ deleted: boolean }>

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

await fulkruma.webhooks.deleteEndpoint('whe_01HX...');

webhooks.listEvents

Signature. fulkruma.webhooks.listEvents(params?: { limit?: number; cursor?: string; type?: string; status?: 'pending' | 'sent' | 'failed'; endpointId?: string }): Promise<{ events: WebhookDelivery[]; nextCursor: string | null }>

The delivery log, newest first — 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). limit is 1–200 (default 50); pass nextCursor back as cursor for the next page.

// Everything that gave up on one endpoint, page by page.
let cursor: string | undefined;
do {
  const page = await fulkruma.webhooks.listEvents({ endpointId: 'clx8n4…', status: 'failed', limit: 100, cursor });
  for (const d of page.events) console.log(d.eventId, d.type, d.lastError);
  cursor = page.nextCursor ?? undefined;
} while (cursor);

webhooks.getEvent

Signature. fulkruma.webhooks.getEvent(id): Promise<{ event: WebhookDelivery }>

One delivery with every attempt made at it.

webhooks.retryEvent

Signature. fulkruma.webhooks.retryEvent(id): Promise<{ event: WebhookDelivery }>

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 getEvent. Fails with 409 (ALREADY_QUEUED, ENDPOINT_DISABLED) when the delivery is already queued or its endpoint is off.

const { events } = await fulkruma.webhooks.listEvents({ status: 'failed' });
for (const d of events) await fulkruma.webhooks.retryEvent(d.id);

Types

interface WebhookEndpoint {          // listEndpoints / updateEndpoint rows (Record<string, unknown> in the SDK)
  id: string;
  accountId: string;
  url: string;
  events: string[];        // ["*"] means every event; "fulkruma.shipment.*" a prefix
  description: string | null;
  active: boolean;
  consecutiveFailures: number;     // failed attempts in a row since the last 2xx
  failingSince: string | null;     // start of the current failure streak
  disabledAt: string | null;       // set when Fulkruma switched it off for failing
  disabledReason: string | null;
  secretPreview: string | null;    // list only: 'whsec_…' + last 4
  createdAt: string;
  updatedAt: string;
}

// Exported by the SDK.
interface WebhookDelivery {
  id: string;
  accountId: string;
  endpointId: string;
  eventId: string;         // the envelope's evt_… id, the same on every attempt
  type: string;            // e.g. 'fulkruma.shipment.status_updated.v1'
  payload: WebhookEventEnvelope;   // the body sent
  status: 'pending' | 'sent' | 'failed';
  attempts: number;
  lastAttemptAt: string | null;
  nextRetryAt: string | null;
  responseCode: number | null;     // null when no response came back
  responseBody: string | null;     // first 2 KiB of the last response
  lastError: string | null;        // 'HTTP 503', 'timed out after 10000ms', 'blocked: …'
  durationMs: number | null;
  deliveredAt: string | null;
  createdAt: string;
  updatedAt: string;
  deliveryAttempts: WebhookDeliveryAttempt[];   // oldest first
}

interface WebhookDeliveryAttempt {
  id: string;
  webhookEventId: string;
  accountId: string;
  endpointId: string;
  attemptNumber: number;
  status: 'succeeded' | 'failed';
  responseCode: number | null;
  durationMs: number;
  error: string | null;
  nextRetryAt: string | null;      // the retry this failure scheduled
  attemptedAt: string;
}

For the full event-type catalog and per-type payload schemas, see Webhook events (overview links to each specific event page).

Common patterns

Register at deploy time

If you provision endpoints via IaC, run create + stash the secret atomically:

async function ensureEndpoint(url: string, events: string[]) {
  const { endpoints } = await fulkruma.webhooks.listEndpoints();
  const existing = (endpoints as any[]).find((e) => e.url === url);
  if (existing) return existing;
  const { endpoint, secret } = await fulkruma.webhooks.createEndpoint({ url, events });
  await secretManager.put(`FULKRUMA_WEBHOOK_SECRET/${endpoint.id}`, secret);
  return endpoint;
}

Verify inbound deliveries

The SDK ships a verifyWebhook 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. Sketch:

import { verifyWebhook } from '@forjio/fulkruma-node';
import express from 'express';

const app = express();
app.post('/webhooks/fulkruma', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifyWebhook({
      rawBody: req.body,                            // raw Buffer
      signature: req.header('Fulkruma-Signature'),
      secret: process.env.FULKRUMA_WEBHOOK_SECRET!,
    });
    // event is the typed delivery; handle by type
    res.status(200).end();
  } catch (err) {
    res.status(400).end();
  }
});

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) is for stopping 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:

await fulkruma.webhooks.updateEndpoint('clx8n4…', { active: true });
let cursor: string | undefined;
do {
  const page = await fulkruma.webhooks.listEvents({ endpointId: 'clx8n4…', status: 'failed', cursor });
  for (const d of page.events) await fulkruma.webhooks.retryEvent(d.id);
  cursor = page.nextCursor ?? undefined;
} while (cursor);

Per-environment endpoints

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

await fulkruma.webhooks.createEndpoint({
  url: 'https://staging.your-app.example.com/webhooks/fulkruma',
  description: 'staging',
});
await fulkruma.webhooks.createEndpoint({
  url: 'https://prod.your-app.example.com/webhooks/fulkruma',
  description: 'prod',
});

Errors

Code Status Cause
VALIDATION 400 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.
NO_ACCOUNT 403 The credentials resolve to no workspace.
NOT_FOUND 404 No endpoint (update / delete) or delivery (getEvent / retryEvent) with that ID in this workspace.
ALREADY_QUEUED 409 retryEvent on a delivery that is already pending.
ENDPOINT_DISABLED 409 retryEvent while the delivery's endpoint is paused or switched off.

Next