Skip to content

Repository files navigation

@posthook/node

The official Node.js/TypeScript SDK for Posthook — schedule webhooks and deliver them reliably.

Installation

npm install @posthook/node

Requirements: Node.js 18+ (uses native fetch). One runtime dependency (ws).

Quick Start

importPosthookfrom'@posthook/node';constposthook=newPosthook('pk_...');// Schedule a webhook 5 minutes from nowconsthook=awaitposthook.hooks.schedule({path: '/webhooks/user-created',postIn: '5m',data: {userId: '123',event: 'user.created'},});console.log(hook.id);// UUIDconsole.log(hook.status);// 'pending'

How it works

Posthook delivers webhooks to {your project domain}{path}. Configure your domain in the Posthook dashboard.

Configuration

constposthook=newPosthook('pk_...',{baseURL: 'https://api.posthook.io',// defaulttimeout: 30000,// default, in mssigningKey: 'ph_sk_...',// for verifying incoming deliveries});

Environment variables

VariableDescription
POSTHOOK_API_KEYFallback API key (used when no key is passed to the constructor)
POSTHOOK_SIGNING_KEYFallback signing key for signature verification

Scheduling hooks

Relative delay (postIn)

Schedule a webhook relative to now. Accepts s (seconds), m (minutes), h (hours), or d (days).

consthook=awaitposthook.hooks.schedule({path: '/webhooks/send-reminder',postIn: '30m',data: {userId: '123'},});

Absolute UTC time (postAt)

Schedule at a specific UTC time in RFC 3339 format.

consthook=awaitposthook.hooks.schedule({path: '/webhooks/send-reminder',postAt: '2025-06-15T10:00:00Z',data: {userId: '123'},});

Local time with timezone (postAtLocal)

Schedule at a local time. Posthook handles DST transitions automatically.

consthook=awaitposthook.hooks.schedule({path: '/webhooks/send-reminder',postAtLocal: '2025-06-15T10:00:00',timezone: 'America/New_York',data: {userId: '123'},});

Quota info

After scheduling, quota info is available on the returned hook:

consthook=awaitposthook.hooks.schedule({ ... });if(hook._quota){console.log(`${hook._quota.remaining} hooks remaining`);console.log(`Resets at ${hook._quota.resetsAt}`);}

Per-hook retry override

Override your project's retry settings for a specific hook:

consthook=awaitposthook.hooks.schedule({path: '/webhooks/critical',postIn: '5m',data: {orderId: 'abc'},retryOverride: {minRetries: 10,delaySecs: 30,strategy: 'exponential',backoffFactor: 2.0,maxDelaySecs: 600,jitter: true,},});

Managing hooks

List hooks

// List failed hooksconsthooks=awaitposthook.hooks.list({status: 'failed',limit: 50});// Cursor-based paginationconstnextPage=awaitposthook.hooks.list({status: 'failed',limit: 50,postAtAfter: hooks[hooks.length-1].postAt,});

Auto-paginating iterator (listAll)

listAll yields every matching hook across all pages automatically:

forawait(consthookofposthook.hooks.listAll({status: 'failed'})){console.log(hook.id,hook.failureError);}

Get a hook

consthook=awaitposthook.hooks.get('hook-uuid');

Delete a hook

To cancel a pending hook, delete it before delivery. Idempotent — a 404 (already deleted) is not an error and the call returns silently.

awaitposthook.hooks.delete('hook-uuid');

Bulk retry / replay / cancel

// Retry specific failed hooksconstresult=awaitposthook.hooks.bulk.retry({hookIDs: ['id-1','id-2'],});console.log(`${result.affected} hooks retried`);// Retry by time range filterconstresult2=awaitposthook.hooks.bulk.retry({startTime: '2025-01-01T00:00:00Z',endTime: '2025-01-02T00:00:00Z',limit: 100,});// Replay completed hooksawaitposthook.hooks.bulk.replay({hookIDs: ['id-1']});// Cancel pending hooksawaitposthook.hooks.bulk.cancel({hookIDs: ['id-1']});

Handling deliveries

Use parseDelivery() to verify the signature and parse the incoming webhook into a typed object.

Important: You must pass the raw request body (string or Buffer), not a parsed JSON object. If you use express.json(), the body will already be parsed and signature verification will fail.

Express

importexpressfrom'express';importPosthookfrom'@posthook/node';constapp=express();constposthook=newPosthook('pk_...',{signingKey: 'ph_sk_...'});// Use express.raw() to get the raw body for signature verificationapp.post('/webhooks/user-created',express.raw({type: '*/*'}),(req,res)=>{try{constdelivery=posthook.signatures.parseDelivery<{userId: string}>(req.body,req.headers,);console.log(delivery.hookId);// hook IDconsole.log(delivery.data.userId);// typed as stringconsole.log(delivery.postAt);// scheduled timeconsole.log(delivery.postedAt);// actual delivery timeres.sendStatus(200);}catch(err){console.error('Signature verification failed:',err);res.sendStatus(400);}});

Fastify

importFastifyfrom'fastify';importPosthookfrom'@posthook/node';constfastify=Fastify({// Add raw body for signature verificationrawBody: true,});constposthook=newPosthook('pk_...',{signingKey: 'ph_sk_...'});fastify.post('/webhooks/user-created',(req,reply)=>{constdelivery=posthook.signatures.parseDelivery<{userId: string}>(req.rawBody!,req.headers,);console.log(delivery.data.userId);reply.code(200).send();});

Generic Node.js HTTP

import{createServer}from'node:http';importPosthookfrom'@posthook/node';constposthook=newPosthook('pk_...',{signingKey: 'ph_sk_...'});createServer((req,res)=>{constchunks: Buffer[]=[];req.on('data',(chunk)=>chunks.push(chunk));req.on('end',()=>{constbody=Buffer.concat(chunks);try{constdelivery=posthook.signatures.parseDelivery(body,req.headers);console.log(delivery.data);res.writeHead(200);res.end();}catch{res.writeHead(400);res.end();}});}).listen(3000);

Async Hooks

When async hooks are enabled, parseDelivery() returns ack and nack methods on the delivery object. Return 202 from your handler and call back when processing completes.

app.post('/webhooks/process-video',express.raw({type: '*/*'}),async(req,res)=>{constdelivery=posthook.signatures.parseDelivery<{videoId: string}>(req.body,req.headers,);res.status(202).end();try{awaitprocessVideo(delivery.data.videoId);awaitdelivery.ack();}catch(err){awaitdelivery.nack({error: err.message});}});

Both ack() and nack() return a CallbackResult:

constresult=awaitdelivery.ack();console.log(result.applied);// true if state changed, false if already resolvedconsole.log(result.status);// "completed", "not_found", "conflict", etc.

ack() and nack() resolve without throwing for 200, 404, and 409 responses. They throw CallbackError for 401 (invalid token) and 410 (expired).

If processing happens in a separate worker, use the raw callback URLs instead:

// Pass URLs through your queueawaitqueue.add('transcode',{videoId: delivery.data.videoId,ackUrl: delivery.ackUrl,nackUrl: delivery.nackUrl,});

WebSocket listener

Receive hooks in real time over a persistent WebSocket connection instead of an HTTP endpoint. Enable WebSocket delivery in your project settings first.

Callback style (listen)

Pass a handler function. The SDK manages the connection, heartbeat, and reconnection automatically.

importPosthook,{Result}from'@posthook/node';constposthook=newPosthook('pk_...');constlistener=awaitposthook.hooks.listen(async(delivery)=>{console.log(delivery.hookId,delivery.data);// Return Result.ack() to mark successreturnResult.ack();},{maxConcurrency: 5,// default: unlimitedonConnected: (info)=>console.log('Connected:',info.projectName),onDisconnected: (err)=>console.log('Disconnected:',err?.message),onReconnecting: (attempt)=>console.log(`Reconnecting (attempt ${attempt})...`),});// Block until the listener is closedawaitlistener.wait();

Result types:

FactoryEffect
Result.ack()Processing complete — hook is marked as delivered immediately
Result.nack(error?)Reject — triggers retry according to project settings
Result.accept(timeoutSecs)Async — you have timeoutSecs to call back via HTTP (see below)

If your handler throws, the SDK automatically sends a nack with the error message.

Async processing with accept

Use accept when your handler needs more time than the 10-second ack window (e.g., video processing, third-party API calls). After returning accept, you must POST to the callback URLs on the delivery to report the outcome:

constlistener=awaitposthook.hooks.listen(async(delivery)=>{// Kick off background work and accept immediatelybackgroundQueue.add({ ...delivery.data,ackUrl: delivery.ackUrl,nackUrl: delivery.nackUrl});returnResult.accept(300);// 5 minutes to call back});// Later, in the background worker:awaitfetch(job.ackUrl,{method: 'POST'});// or on failure:awaitfetch(job.nackUrl,{method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({error: 'failed'})});

If neither URL is called before the deadline, the hook is retried.

Async iterator style (stream)

For more control, use stream() which returns an AsyncIterable. You must explicitly ack, accept, or nack each delivery.

conststream=awaitposthook.hooks.stream({onConnected: (info)=>console.log('Connected:',info.projectName),});forawait(constdeliveryofstream){console.log(delivery.hookId,delivery.data);console.log(delivery.ws?.attempt,'of',delivery.ws?.maxAttempts);stream.ack(delivery.hookId);// or: stream.accept(delivery.hookId, 300);// or: stream.nack(delivery.hookId, 'bad data');}

HTTP fallback

If your project has a domain configured, hooks are delivered via HTTP when no WebSocket listener is connected. You can run both an HTTP endpoint and a WebSocket listener — the server uses WebSocket when available and falls back to HTTP automatically. Since both paths use the same Result type, you can share your handler logic:

asyncfunctionprocessHook(delivery: PosthookDelivery): Promise<Result>{awaitprocessOrder(delivery.data);returnResult.ack();}// HTTP delivery (Express endpoint)app.post('/webhooks/order',express.raw({type: '*/*'}),posthook.signatures.expressHandler(processHook));// WebSocket delivery (runs alongside)constlistener=awaitposthook.hooks.listen(processHook);

Connection lifecycle

  • Reconnection: On disconnect the SDK reconnects with exponential backoff (min(1s * 2^attempts, 30s)), up to 10 attempts. The counter resets on a successful connection.
  • Heartbeat: If no server activity is detected for 45 seconds the connection is considered stale and force-closed for reconnection.
  • Auth errors: Close codes 4001 and 4003 abort immediately without reconnecting.

Express handler

signatures.expressHandler() wraps signature verification and Result dispatch into a single Express-compatible middleware:

importexpressfrom'express';importPosthook,{Result}from'@posthook/node';constapp=express();constposthook=newPosthook('pk_...',{signingKey: 'ph_sk_...'});app.post('/webhooks/order',express.raw({type: '*/*'}),posthook.signatures.expressHandler(async(delivery)=>{awaitprocessOrder(delivery.data);returnResult.ack();// 200 { ok: true }// Result.accept(60) -> 202 { ok: true }// Result.nack('bad') -> 500 { error: 'bad' }}),);

Handler response codes

Posthook interprets your handler's HTTP response:

  • 2xx = success (delivery complete, hook marked as completed)
  • Anything else = failure (triggers retry according to your project/hook retry settings)

This includes 3xx redirects — they are treated as failures. Response body is ignored. Just return 200.

Idempotency

Use delivery.hookId as the idempotency key. The same hook ID is sent on every retry attempt.

app.post('/webhooks/charge',express.raw({type: '*/*'}),async(req,res)=>{constdelivery=posthook.signatures.parseDelivery<{orderId: string}>(req.body,req.headers,);// Check if already processedconstexists=awaitdb.query('SELECT 1 FROM processed_hooks WHERE hook_id = $1',[delivery.hookId]);if(exists.rows.length>0){returnres.sendStatus(200);// Already processed, return success}// Process the webhookawaitchargeOrder(delivery.data.orderId);// Mark as processedawaitdb.query('INSERT INTO processed_hooks (hook_id) VALUES ($1)',[delivery.hookId]);res.sendStatus(200);});

Error handling

All errors extend PosthookError and can be caught with instanceof:

importPosthook,{PosthookError,AuthenticationError,RateLimitError,NotFoundError,}from'@posthook/node';try{awaitposthook.hooks.schedule({path: '/test',postIn: '5m'});}catch(err){if(errinstanceofRateLimitError){console.log('Rate limited, retry later');}elseif(errinstanceofAuthenticationError){console.log('Invalid API key');}elseif(errinstanceofNotFoundError){console.log('Hook not found');}elseif(errinstanceofPosthookError){console.log(`API error: ${err.message} (${err.code})`);}}
Error classHTTP StatusCode
BadRequestError400bad_request
AuthenticationError401authentication_error
ForbiddenError403forbidden
NotFoundError404not_found
PayloadTooLargeError413payload_too_large
RateLimitError429rate_limit_exceeded
InternalServerError500+internal_error
ConnectionErrorconnection_error
SignatureVerificationErrorsignature_verification_error
WebSocketErrorwebsocket_error

TypeScript

All types are exported from the package:

importPosthook,{Result,typeHook,typeHookScheduleParams,typeHookListParams,typeHookListAllParams,typeDuration,typePosthookDelivery,typeWebSocketMeta,typeConnectionInfo,typeListenOptions,typeStreamOptions,typeListenHandler,typeQuotaInfo,typeBulkActionResult,typeBulkActionParams,}from'@posthook/node';

Generics

Both schedule and parseDelivery accept a generic type parameter for the data payload:

interfaceUserEvent{userId: string;event: string;}// Type-safe schedulingconsthook=awaitposthook.hooks.schedule<UserEvent>({path: '/webhooks/user',postIn: '5m',data: {userId: '123',event: 'created'},// typed});console.log(hook.data.userId);// typed as string// Type-safe delivery parsingconstdelivery=posthook.signatures.parseDelivery<UserEvent>(body,headers);console.log(delivery.data.userId);// typed as string

Discriminated unions

HookScheduleParams is a discriminated union — TypeScript enforces that exactly one scheduling mode is used:

// OK: postIn modeposthook.hooks.schedule({path: '/test',postIn: '5m'});// OK: postAtLocal mode (timezone required)posthook.hooks.schedule({path: '/test',postAtLocal: '2025-01-15T10:00:00',timezone: 'US/Eastern'});// Type error: can't mix modesposthook.hooks.schedule({path: '/test',postIn: '5m',postAt: '...'});// Type error: timezone requires postAtLocalposthook.hooks.schedule({path: '/test',postAt: '...',timezone: 'US/Eastern'});

Resources

Requirements

  • Node.js 18+
  • Runtime dependency: ws (WebSocket client)

About

Official Node.js/TypeScript SDK for Posthook

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages