Webhooks

Manage your outbound webhook and parse incoming deliveries into typed events. See the Admin overview for how to create the admin client, and the Webhooks API for the underlying event model.

Webhook management lives on the admin client (admin.webhooks); signature verification and event parsing are standalone helpers exported from the package root.

Register a webhook

A client may hold one active webhook. It receives every event Linked API emits, unless you select events.

typescript
const webhook = await admin.webhooks.set({
  url: 'https://example.com/hooks/linkedapi',
  payloadMode: 'fat', // 'fat' inlines the workflow result; 'thin' sends a reference only
});

console.log(`Webhook ${webhook.id} → ${webhook.url}`);

Params

  • url – HTTPS endpoint that will receive deliveries.
  • payloadMode / payload_mode – optional, "fat" (default) or "thin".
  • signingEnabled / signing_enabled – optional, true to sign deliveries; the response then carries secret.
  • headers – optional custom headers, a name-to-value map sent with every delivery.
  • events – optional event selection: exact event types and <namespace>.* wildcards. Omitted or null means every event.

Data

  • id – webhook subscription id.
  • url – the registered destination.
  • payloadMode / payload_mode – current payload mode.
  • signingEnabled / signing_enabled – whether deliveries are signed.
  • headerNames / header_names – names of the configured custom headers, never their values.
  • events – the stored event selection, de-duplicated and sorted, or null for every event.
  • isActive / is_active – whether the webhook is active.
  • createdAt / created_at – ISO 8601 timestamp.
  • secret – the signing secret, present only when the call returns it: a set that enabled signing, enabling setSigning / set_signing, revealSecret / reveal_secret and rotateSecret / rotate_secret.

No method changes the destination – to move it, delete the webhook and set a new one. Everything else changes in place.

Get the active webhook

typescript
const webhooks = await admin.webhooks.get();
console.log(webhooks[0]?.url ?? 'no active webhook');

Returns an array with at most one entry.

Change payload mode

typescript
await admin.webhooks.setPayloadMode({ id: 'whs-...', payloadMode: 'thin' });

Select events

Choose which events the webhook receives: exact event types, such as workflow.completed, and namespace wildcards, such as inbox.*, which also pick up event types added later. null goes back to every event. webhook.test is always delivered. See event selection for the rules, including why a deselected event can never be replayed.

typescript
// The event types a selection can name, in Linked API's order
const eventTypes = await admin.webhooks.eventTypes();

await admin.webhooks.setEvents({
  id: 'whs-...',
  events: ['workflow.completed', 'inbox.*'],
});

// Back to every event
await admin.webhooks.setEvents({ id: 'whs-...', events: null });

eventTypes / event_types works before you register a webhook. To choose events when registering, pass events to set. In Python, the params model rejects an unknown selector as you build it, with a pydantic.ValidationError, before any request is sent. A selection that reaches Linked API and is refused there – any invalid entry from Node – raises a LinkedApiError of type invalidRequestPayload that names the entry.

Sign deliveries

Turn on signing and every delivery carries an HMAC-SHA256 signature your endpoint verifies before trusting it. Enabling returns the secret. You can read it again later, or rotate a leaked one.

typescript
const webhook = await admin.webhooks.setSigning({ id: 'whs-...', signingEnabled: true });
// webhook.secret holds the secret – store it where your endpoint can read it

const { secret } = await admin.webhooks.revealSecret({ id: 'whs-...' });

// Replaces the secret; the old one stops working immediately
const rotated = await admin.webhooks.rotateSecret({ id: 'whs-...' });

Each method returns the webhook, with secret set on the calls above. Rotation has no overlap period: every delivery fails verification until your endpoint holds the new secret, so rotate when you can deploy it.

Custom headers

Send a credential your endpoint requires with every delivery. Values are write-only: reading the webhook returns only headerNames / header_names, so edit one header at a time and leave the others untouched. Limits and reserved names are in custom delivery headers.

typescript
// Add or replace one header; the name is matched case-insensitively
await admin.webhooks.setHeader({
  id: 'whs-...',
  name: 'Authorization',
  value: `Bearer ${process.env.ENDPOINT_TOKEN}`,
});

// Remove one header
await admin.webhooks.deleteHeader({ id: 'whs-...', name: 'Authorization' });

// Replace all headers at once – only when you hold every value; null or {} clears them
await admin.webhooks.setHeaders({ id: 'whs-...', headers: null });

Delete the webhook

Soft delete: the delivery history is preserved and pending deliveries are dropped. You can register a fresh webhook afterwards.

typescript
await admin.webhooks.delete({ id: 'whs-...' });

Inspect deliveries

Return the most recent deliveries (newest first) as a debug feed.

typescript
const deliveries = await admin.webhooks.deliveries();

for (const delivery of deliveries) {
  console.log(`${delivery.eventType} → ${delivery.status} (attempts: ${delivery.attempts})`);
  if (delivery.lastError) {
    console.log(`  last error: ${delivery.lastError} (HTTP ${delivery.responseStatusCode})`);
  }
}

Data

Each delivery has the following shape:

typescript
interface TWebhookDelivery {
  id: string; // delivery identifier, passed to replayDelivery
  eventType: TWebhookEventType; // the event type that was delivered
  eventId: string; // the envelope id of the delivered event
  status: 'pending' | 'delivering' | 'success' | 'failed';
  attempts: number; // delivery attempts made so far
  responseStatusCode: number | null; // HTTP status your endpoint returned on the last attempt
  lastError: string | null; // error text from the last failed attempt
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
}

Replay a delivery

Re-arm an already-settled delivery. The same event id is reused, so your idempotency handling still applies.

typescript
await admin.webhooks.replayDelivery({ deliveryId: 'whd-...' });

Send a test event

Emit a synthetic webhook.test event to verify a freshly registered endpoint end-to-end.

typescript
await admin.webhooks.sendTest();

Receiving and parsing events

Use parseWebhookEvent / parse_webhook_event to turn a raw request body into a typed, discriminated event. Pass the raw body exactly as received, then branch on type to narrow data.

If signing is on, check the signature first with verifyWebhookSignature / verify_webhook_signature, using the raw body and the two signature headers, whose names are exported as WEBHOOK_SIGNATURE_HEADER and WEBHOOK_SIGNATURE_TIMESTAMP_HEADER. The signature covers the exact bytes we sent, so a body that has been parsed and re-serialized will not verify: verify first, then parse.

typescript
import express from 'express';
import {
  parseWebhookEvent,
  verifyWebhookSignature,
  WEBHOOK_SIGNATURE_HEADER,
  WEBHOOK_SIGNATURE_TIMESTAMP_HEADER,
} from '@linkedapi/node';

const app = express();
app.use(express.raw({ type: 'application/json' }));

app.post('/hooks/linkedapi', (req, res) => {
  // Skip this check if signing is off for your webhook.
  const isAuthentic = verifyWebhookSignature({
    rawBody: req.body as Buffer,
    timestamp: req.headers[WEBHOOK_SIGNATURE_TIMESTAMP_HEADER],
    signature: req.headers[WEBHOOK_SIGNATURE_HEADER],
    secret: process.env.LINKED_API_WEBHOOK_SECRET,
  });
  if (!isAuthentic) {
    res.sendStatus(401);
    return;
  }

  const event = parseWebhookEvent(req.body as Buffer);

  switch (event.type) {
    case 'workflow.completed':
      console.log(`Workflow ${event.data.workflowId} finished: ${event.data.status}`);
      console.log('Result:', event.data.result); // present in 'fat' mode only
      break;
    case 'account.reconnectionRequired':
      console.log(`Account ${event.data.accountId} needs reconnection.`);
      break;
    case 'inbox.messageReceived':
      // personHashedUrl is always present; personPublicUrl is the public one and
      // is null until that person has been resolved.
      console.log(`New message from ${event.data.personPublicUrl ?? event.data.personHashedUrl}: ${event.data.text}`);
      break;
    case 'inbox.messageSent':
      console.log(`Outgoing message in thread ${event.data.threadId}`);
      break;
    case 'network.connectionAccepted':
      console.log(`${event.data.personUrl} accepted your connection request`);
      break;
    case 'network.connectionRequestReceived':
      console.log(`New connection request from ${event.data.personUrl}`);
      break;
    case 'webhook.test':
      console.log('Test event:', event.data.message);
      break;
  }

  // Acknowledge fast. A non-2xx response makes Linked API retry with backoff.
  res.sendStatus(200);
});

verifyWebhookSignature / verify_webhook_signature returns true or false and never throws: a missing or malformed header, an empty secret or a body of the wrong type is simply false. It uses the secret as the literal text you received – never hex-decode it – and accepts a timestamp up to 300 seconds away from your clock in either direction. Change that window with toleranceSeconds / tolerance_seconds, and pass nowSeconds / now_seconds to check a stored delivery against the time it arrived.

parseWebhookEvent / parse_webhook_event throws when the body is not valid JSON or is missing the id / type envelope fields. Deduplicate on event.id – deliveries are at-least-once, so the same event can arrive more than once.

The returned union (TWebhookEvent in Node, WebhookEvent in Python) covers workflow events, account events, inbox message events, network events, and the test event. See the Webhooks API for the full event catalog.

Inbox message events carry two URLs for the other participant: personHashedUrl, the hashed one, always present and accepted by every method that takes a person URL; and personPublicUrl, the public one, which is null on any message whose person has not been resolved – including the first message in a thread. See pollInbox for how it is resolved. The payload also still carries personUrl, deprecated and replaced by personHashedUrl – it holds the same value and stays only so existing subscribers keep working.

Errors

All webhook methods throw LinkedApiError on failure – see Admin overview. For the complete HTTP reference, see the Webhooks API docs.