The official Node.js/TypeScript SDK for Posthook — schedule webhooks and deliver them reliably.
npm install @posthook/nodeRequirements: Node.js 18+ (uses native fetch). One runtime dependency (ws).
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'Posthook delivers webhooks to {your project domain}{path}. Configure your domain in the Posthook dashboard.
constposthook=newPosthook('pk_...',{baseURL: 'https://api.posthook.io',// defaulttimeout: 30000,// default, in mssigningKey: 'ph_sk_...',// for verifying incoming deliveries});| Variable | Description |
|---|---|
POSTHOOK_API_KEY | Fallback API key (used when no key is passed to the constructor) |
POSTHOOK_SIGNING_KEY | Fallback signing key for signature verification |
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'},});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'},});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'},});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}`);}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,},});// 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,});listAll yields every matching hook across all pages automatically:
forawait(consthookofposthook.hooks.listAll({status: 'failed'})){console.log(hook.id,hook.failureError);}consthook=awaitposthook.hooks.get('hook-uuid');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');// 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']});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.
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);}});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();});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);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,});Receive hooks in real time over a persistent WebSocket connection instead of an HTTP endpoint. Enable WebSocket delivery in your project settings first.
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:
| Factory | Effect |
|---|---|
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.
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.
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');}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);- 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
4001and4003abort immediately without reconnecting.
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' }}),);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.
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);});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 class | HTTP Status | Code |
|---|---|---|
BadRequestError | 400 | bad_request |
AuthenticationError | 401 | authentication_error |
ForbiddenError | 403 | forbidden |
NotFoundError | 404 | not_found |
PayloadTooLargeError | 413 | payload_too_large |
RateLimitError | 429 | rate_limit_exceeded |
InternalServerError | 500+ | internal_error |
ConnectionError | — | connection_error |
SignatureVerificationError | — | signature_verification_error |
WebSocketError | — | websocket_error |
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';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 stringHookScheduleParams 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'});- Documentation — guides, concepts, and patterns
- API Reference — endpoint specs and examples
- Quickstart — get started in under 2 minutes
- Pricing — free tier included
- Status — uptime and incident history
- Node.js 18+
- Runtime dependency:
ws(WebSocket client)