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.
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,trueto sign deliveries; the response then carriessecret.headers– optional custom headers, a name-to-value map sent with every delivery.events– optional event selection: exact event types and<namespace>.*wildcards. Omitted ornullmeans 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, ornullfor 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: asetthat enabled signing, enablingsetSigning/set_signing,revealSecret/reveal_secretandrotateSecret/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
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
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.
// 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.
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.
// 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.
await admin.webhooks.delete({ id: 'whs-...' });Inspect deliveries
Return the most recent deliveries (newest first) as a debug feed.
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:
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.
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.
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.
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.