# Bulk Cancel Hooks Source: https://docs.posthook.io/api-reference/endpoint/bulk-cancel POST /hooks/bulk/cancel Cancel pending hooks in bulk. Provide specific hook IDs or use time-range filters. Cancel pending hooks in bulk. Cancelled hooks will not be delivered. Bulk actions availability may vary by plan. ### SDK Methods | Language | By IDs | By Filter | | ---------- | ----------------------------------------------------- | ----------------------------------------------------------------- | | TypeScript | `posthook.hooks.bulk.cancel({ hookIDs })` | `posthook.hooks.bulk.cancel({ startTime, endTime, limit })` | | Python | `client.hooks.bulk.cancel(hook_ids)` | `client.hooks.bulk.cancel_by_filter(start_time, end_time, limit)` | | Go | `client.Hooks.Bulk().Cancel(ctx, &BulkActionByIDs{})` | `client.Hooks.Bulk().CancelByFilter(ctx, &BulkActionByFilter{})` | All methods return `{ affected: number }` — the count of hooks that were cancelled. # Bulk Replay Hooks Source: https://docs.posthook.io/api-reference/endpoint/bulk-replay POST /hooks/bulk/replay Replay completed hooks in bulk. Creates new hook deliveries with the same payload. Provide specific hook IDs or use time-range filters. Replay completed hooks in bulk — creating new deliveries with the same payload. Useful for re-processing hooks after a bug fix or data migration. Bulk actions availability may vary by plan. ### SDK Methods | Language | By IDs | By Filter | | ---------- | ----------------------------------------------------- | ----------------------------------------------------------------- | | TypeScript | `posthook.hooks.bulk.replay({ hookIDs })` | `posthook.hooks.bulk.replay({ startTime, endTime, limit })` | | Python | `client.hooks.bulk.replay(hook_ids)` | `client.hooks.bulk.replay_by_filter(start_time, end_time, limit)` | | Go | `client.Hooks.Bulk().Replay(ctx, &BulkActionByIDs{})` | `client.Hooks.Bulk().ReplayByFilter(ctx, &BulkActionByFilter{})` | All methods return `{ affected: number }` — the count of hooks that were replayed. # Bulk Retry Hooks Source: https://docs.posthook.io/api-reference/endpoint/bulk-retry POST /hooks/bulk/retry Retry failed hooks in bulk. Provide specific hook IDs or use time-range filters. Retry failed hooks in bulk — either by specifying individual hook IDs or by applying a time-range filter. Bulk actions availability may vary by plan. ### SDK Methods | Language | By IDs | By Filter | | ---------- | ---------------------------------------------------- | ---------------------------------------------------------------- | | TypeScript | `posthook.hooks.bulk.retry({ hookIDs })` | `posthook.hooks.bulk.retry({ startTime, endTime, limit })` | | Python | `client.hooks.bulk.retry(hook_ids)` | `client.hooks.bulk.retry_by_filter(start_time, end_time, limit)` | | Go | `client.Hooks.Bulk().Retry(ctx, &BulkActionByIDs{})` | `client.Hooks.Bulk().RetryByFilter(ctx, &BulkActionByFilter{})` | All methods return `{ affected: number }` — the count of hooks that were retried. # Schedule Hook Source: https://docs.posthook.io/api-reference/endpoint/create-hook POST /hooks Schedule a new hook for delivery at a specific time. Supports UTC, local-time, and relative-delay scheduling modes. Schedule a new webhook to be sent at a specific time in the future (or immediately). **Pro Tip**: To verify immediate delivery, use a timestamp in the past (e.g. yesterday). ## URL Resolution When you schedule a hook, you provide a `path` (e.g. `/webhooks/remind`). Posthook constructs the full destination URL by combining your **Project Domain** with this `path`. > **Destination URL** = `Project Domain` + `path` For example, if your Project Domain is `api.myapp.com` and you schedule a hook with path `/webhooks/payment`, we will POST to `https://api.myapp.com/webhooks/payment` (Posthook enforces HTTPS). ## Usage Patterns * [**User Engagement**](/patterns/user-engagement): Schedule event reminders and abandoned cart recovery emails. * [**Auto-Expiry (TTL)**](/patterns/auto-expiry): Schedule the deletion of temporary resources (e.g. shared links) at the moment of creation. * [**Recursive Polling**](/patterns/polling): Periodically check a long-running job (e.g. video encoding) until it completes. * [**Debouncing**](/patterns/debouncing): Prevent duplicate processing by verifying event freshness. # Delete Hook Source: https://docs.posthook.io/api-reference/endpoint/delete-hook DELETE /hooks/{id} Cancel a hook. The hook will be cancelled immediately and deleted. Delete a hook by ID. This can be used to cancel a pending hook or remove a past hook record. ## Parameters The ID of the hook to cancel. # Get Hook Source: https://docs.posthook.io/api-reference/endpoint/get-hook GET /hooks/{id} Retrieve a single hook by ID. Retrieve details about a specific hook. ## Parameters The ID of the hook to retrieve. # List Hooks Source: https://docs.posthook.io/api-reference/endpoint/list-hooks GET /hooks Retrieve a paginated list of hooks for the current project. Supports cursor-based pagination and filtering by status. List your webhooks with cursor-based pagination. ## Cursor-Based Pagination Results are paginated using an opaque cursor. Pass the `cursor` from the previous response to fetch the next page. When no more results are available, no cursor is returned. The `offset` parameter has been removed. Use `cursor` for pagination instead. ### SDK Methods | Language | Single Page | Auto-Paginate | | ---------- | ------------------------------------------- | ------------------------------------------------- | | TypeScript | `posthook.hooks.list({ status, limit })` | `posthook.hooks.listAll({ status })` | | Python | `client.hooks.list(status, limit)` | `client.hooks.list_all(status)` | | Go | `client.Hooks.List(ctx, &HookListParams{})` | `client.Hooks.ListAll(ctx, &HookListAllParams{})` | The `listAll` / `list_all` / `ListAll` methods return an iterator that handles cursor pagination automatically. ## Usage Patterns Common patterns for this endpoint include: * [**Dead Letter Queue**](/patterns/dead-letter-queue): Process failures within a specific time window. * [**Bulk Retry**](/patterns/dead-letter-queue#bulk-retry): Batch retry failed hooks. * [**Auditing**](/patterns/auditing): Reconcile Posthook delivery logs with your internal records. # Introduction Source: https://docs.posthook.io/api-reference/introduction Posthook API Reference The Posthook API follows REST conventions with predictable, resource-oriented URLs. It accepts JSON-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs. ## Base URL All API requests should be made to: ```bash theme={null} https://api.posthook.io/v1 ``` ## SDKs Official SDKs handle authentication, pagination, and error handling automatically. ```bash TypeScript theme={null} npm install @posthook/node ``` ```bash Python theme={null} pip install posthook-python ``` ```bash Go theme={null} go get github.com/posthook/posthook-go ``` Initialize the client with your API key: ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; const posthook = new Posthook('phk_your_api_key'); ``` ```python Python theme={null} from posthook import Posthook client = Posthook("phk_your_api_key") ``` ```go Go theme={null} import "github.com/posthook/posthook-go" client, err := posthook.NewClient("phk_your_api_key") ``` ## Authentication The Posthook API uses API keys to authenticate requests. ```bash theme={null} X-API-Key: ``` See the [Authentication](/essentials/authentication) guide for more details. ## Response Codes Posthook uses standard HTTP response codes to indicate the success or failure of an API request. | Code | Meaning | | ----- | --------------------------------------------------------------------- | | `200` | **OK** - Request succeeded. | | `201` | **Created** - The resource was successfully created. | | `400` | **Bad Request** - Invalid request (e.g., missing required fields). | | `401` | **Unauthorized** - The `X-API-Key` header is missing or invalid. | | `404` | **Not Found** - The requested resource (e.g. Hook ID) does not exist. | | `413` | **Payload Too Large** - The request body exceeds the maximum size. | | `429` | **Too Many Requests** - You have exceeded your rate limits. | | `500` | **Internal Server Error** - Something went wrong on our end. | # Changelog Source: https://docs.posthook.io/changelog Latest updates and improvements to Posthook Have a feature request? [Let us know](https://github.com/posthook/community/discussions/new?category=feature-requests). *** ## March 2026 **[Async Hooks (Beta)](/essentials/async-hooks)** Return HTTP 202 from your webhook handler and call back when processing completes. Built-in timeout management, retry on failure, and async status visibility in the dashboard. Ideal for long-running tasks that exceed the 10-second delivery timeout. **[Per-Step Retry Override for Sequences](/essentials/sequences#per-step-retry-override)** Override retry settings on individual sequence steps — strategy, delay, backoff, and jitter — instead of inheriting project defaults. *** ## February 2026 **Official SDKs** First-class SDKs for TypeScript, Python, and Go with built-in authentication, pagination, and signature verification. ```bash TypeScript theme={null} npm install @posthook/node ``` ```bash Python theme={null} pip install posthook-python ``` ```bash Go theme={null} go get github.com/posthook/posthook-go ``` **[Bulk Actions](/api-reference/endpoint/bulk-retry)** Retry, replay, or cancel hooks in bulk — by IDs or by time-range filter. ```typescript theme={null} const result = await posthook.hooks.bulk.retry({ hookIDs: ['e5405623-...', 'a1b2c3d4-...'] }); ``` **[Timezone-Aware Scheduling](/essentials/scheduling)** Schedule hooks at a local time with `postAtLocal` + `timezone`. Posthook resolves DST gaps and folds automatically. ```bash theme={null} curl -X POST https://api.posthook.io/v1/hooks \ -H "X-API-Key: $POSTHOOK_API_KEY" \ -d '{ "path": "/webhooks/send-reminder", "postAtLocal": "2026-03-15T09:00:00", "timezone": "America/New_York", "data": { "user_id": "usr_123" } }' ``` **[Calendar Schedules for Sequences](/essentials/sequences#calendar-scheduling)** Sequences now support calendar-based scheduling (hourly, daily, weekly, monthly) with timezone support for running workflows at specific clock times. **[Per-Hook Retry Override](/essentials/scheduling#per-hook-retry-override)** Override your project's default retry settings on individual hooks — strategy, delay, backoff, and jitter. **[Relative Delay Scheduling](/essentials/scheduling#relative-delay-with-postin)** Schedule hooks relative to now using duration strings like `"5m"`, `"2h"`, or `"1d12h"`. **[Config-as-Code](/essentials/config-as-code)** Manage project settings declaratively with `posthook.toml`. Pull, diff, and apply configuration changes from the CLI. Supports multi-environment configs via `--config`. **[Usage Quota Headers](/essentials/scheduling#quota-headers)** `POST /hooks` responses now include `Posthook-HookQuota-*` headers showing plan usage, remaining quota, and reset time. **Endpoint Pattern Normalization** Endpoint paths are now normalized for consistent analytics grouping. *** ## January 2026 **[Team Management](/essentials/teams)** Invite team members with role-based access. Admins manage billing and settings; members manage hooks and projects. All activity is tracked in a built-in audit log. **Account Closure** Close your account from Dashboard Settings. A grace period allows you to reactivate before data is permanently deleted. **[Notification Channels](/essentials/project-settings#notifications)** Send failure and recovery alerts to Slack, custom webhooks, or email. Configure per-project in Settings or `posthook.toml`. **Duplicate Delivery Prevention** Added a deduplication layer as an extra safeguard against duplicate deliveries during degraded conditions. Your handlers should still be [idempotent](/essentials/receiving-webhooks#idempotency), but this adds another layer of protection. **Usage Alerts** Automatic email notifications at 75%, 100%, and 150% of your plan's hook quota. *** ## December 2025 **[Posthook CLI](/essentials/local-development)** Receive webhooks on your local machine — no tunnels or public URLs required. ```bash theme={null} npx posthook listen --forward http://localhost:8080 ``` * Forward hooks to any local URL * Works behind firewalls and NAT * Automatic reconnection on connection loss **Smarter Failure Notifications** * Baseline tracking for endpoint error rates * Grouped notifications to reduce alert fatigue * Follow-up alerts for persistent issues * Recovery emails when endpoints recover * Detailed error classifications **Activity Page** * Real-time refresh showing latest deliveries * Faster queries **Dashboard UX** * Improved navigation and layout * Streamlined project settings # Async Hooks Source: https://docs.posthook.io/essentials/async-hooks Handle long-running webhook processing with ack/nack callbacks Async hooks let your endpoint return **HTTP 202** to acknowledge receipt, then call back to Posthook when processing completes. This removes the 10-second delivery timeout for long-running tasks like video processing, report generation, or third-party API calls. Async hooks is in beta. Enable it per-project in [Project Settings](/essentials/project-settings#async-hooks-beta). ## Delivery Flow When async hooks are enabled, each delivery includes two extra headers: `Posthook-Ack-URL` and `Posthook-Nack-URL`. 1. Your endpoint receives the delivery and returns **202 Accepted**. 2. Your code processes the work. 3. You call back with the result. | Callback | Result | | ------------------------- | ---------------------------------------------------- | | POST to ack URL | Hook marked **completed** | | POST to nack URL | Hook **retried** or **failed** per your retry policy | | Neither (deadline passes) | Hook **retried** or **failed** per your retry policy | ## Receiving Async Deliveries Return 202 immediately, then process in the background and call `ack()` or `nack()` when done. ```javascript Express.js theme={null} import express from 'express'; import { Signatures, SignatureVerificationError } from '@posthook/node'; const app = express(); const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); app.post('/webhooks/process-video', express.raw({ type: '*/*' }), async (req, res) => { try { const delivery = signatures.parseDelivery(req.body, req.headers); res.status(202).end(); try { await processVideo(delivery.data.videoId); await delivery.ack(); } catch (err) { await delivery.nack({ error: err.message }); } } catch (err) { if (err instanceof SignatureVerificationError) { return res.status(401).json({ error: err.message }); } throw err; } }); ``` ```python FastAPI theme={null} import os from fastapi import FastAPI, Request, BackgroundTasks, HTTPException from fastapi.responses import Response import posthook app = FastAPI() client = posthook.Posthook(os.environ["POSTHOOK_API_KEY"], signing_key=os.environ["POSTHOOK_SIGNING_KEY"]) async def process_and_ack(delivery): try: await process_video(delivery.data["video_id"]) await posthook.async_ack(delivery.ack_url) except Exception as e: await posthook.async_nack(delivery.nack_url, {"error": str(e)}) @app.post('/webhooks/process-video') async def handle_webhook(request: Request, background_tasks: BackgroundTasks): body = await request.body() headers = dict(request.headers) try: delivery = client.signatures.parse_delivery(body, headers) except posthook.SignatureVerificationError: raise HTTPException(status_code=401) background_tasks.add_task(process_and_ack, delivery) return Response(status_code=202) ``` ```go Go (net/http) theme={null} package main import ( "context" "io" "net/http" "os" posthook "github.com/posthook/posthook-go" ) var client, _ = posthook.NewClient(os.Getenv("POSTHOOK_API_KEY"), posthook.WithSigningKey(os.Getenv("POSTHOOK_SIGNING_KEY"))) func handleWebhook(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivery, err := client.Signatures.ParseDelivery(body, r.Header) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } w.WriteHeader(http.StatusAccepted) go func() { if err := processVideo(delivery.Data); err != nil { delivery.Nack(context.Background(), map[string]any{"error": err.Error()}) return } delivery.Ack(context.Background(), nil) }() } ``` Make sure async hooks is enabled in [Project Settings](/essentials/project-settings#async-hooks-beta). Without the callback headers, long-running work will hit the standard 10-second delivery timeout. Developing locally? Async hooks work with the CLI in [forward mode](/essentials/local-development#async-hooks) — the callback headers are forwarded automatically. ## Passing Work to a Queue If processing happens in a separate worker or service, pass the raw callback URLs through your queue and call them from the worker. ```javascript theme={null} // Webhook handler — enqueue and respond app.post('/webhooks/process-video', express.raw({ type: '*/*' }), async (req, res) => { const delivery = signatures.parseDelivery(req.body, req.headers); await videoQueue.add('transcode', { videoId: delivery.data.videoId, ackUrl: delivery.ackUrl, nackUrl: delivery.nackUrl, }); return res.status(202).end(); }); // Worker — process and call back videoQueue.process('transcode', async (job) => { try { await processVideo(job.data.videoId); await fetch(job.data.ackUrl, { method: 'POST' }); } catch (err) { await fetch(job.data.nackUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: err.message }), }); } }); ``` Callback URLs are plain HTTPS endpoints. Any service that can make an HTTP POST can call them — no SDK required. ## Timeouts If neither ack nor nack is received before the deadline, the attempt is treated as a failure and follows your [retry settings](/essentials/receiving-webhooks#retries). The default timeout is **300 seconds (5 minutes)**. Override it per delivery by setting a `Posthook-Async-Timeout` header on your 202 response: ```javascript theme={null} res.setHeader('Posthook-Async-Timeout', '600'); // 10 minutes res.status(202).end(); ``` | | Value | | ------- | ------------------------- | | Minimum | `10` seconds | | Default | `300` seconds (5 min) | | Maximum | `10800` seconds (3 hours) | `Posthook-Async-Timeout` is a **response** header that you set. This is the opposite direction from the other `Posthook-*` headers, which Posthook sends to you. ## Callback Responses When you POST to an ack or nack URL, Posthook returns a status code indicating what happened. | Status | Meaning | Action | | ------ | ------------------------------------------------------------ | ------------------------ | | `200` | Callback accepted, or hook already resolved for this attempt | None | | `401` | Invalid callback token | Check configuration | | `404` | Hook was deleted | None | | `409` | Hook already moved to a newer attempt | None | | `410` | Callback expired (token or deadline elapsed) | None — treat as terminal | **`200`** can mean your callback was applied, or the attempt was already resolved moments earlier (e.g., timeout won the race). Both are terminal for that callback. **`409`** means the hook has already advanced to a newer retry attempt — your callback URL belongs to an older attempt. No action needed; Posthook is handling the newer attempt. **`410`** means the callback URL can no longer affect hook state. The token expired or the async deadline elapsed. Treat it as terminal. ### SDK behavior The SDK `ack()` and `nack()` helpers handle these responses for you: * **200**, **404**, **409** resolve without throwing. `200` includes an `applied` flag indicating whether state actually changed. * **401** and **410** raise a callback error. ### Nack bodies Ack request bodies are ignored. Nack request bodies are captured for diagnostics (up to **8 KB**). Include structured JSON if you want failure details visible in the dashboard. ```javascript theme={null} await delivery.nack({ error: 'Transcoding failed', code: 'FFMPEG_EXIT_1', duration_ms: 45200, }); ``` ## Retries Async failures — both `nack` and timeout — use the same retry logic as synchronous failures. A nack or timeout increments the attempt counter and applies your project's retry policy (or the hook's [`retryOverride`](/essentials/scheduling#per-hook-retry-override)). If the hook has exhausted all retries, it is marked as **failed**. There's nothing new to configure. If your project retries 5 times with exponential backoff, that same policy applies whether the failure came from an HTTP 500, a nack, or a timeout. Async hooks still follow at-least-once delivery. Keep your handlers [idempotent](/essentials/receiving-webhooks#idempotency). ## Monitoring The hook detail page in the dashboard shows async-specific state: * **Awaiting Ack** status while a callback is pending * Ack deadline for the current attempt * Outcome per attempt: ack, nack, or timeout * Elapsed time between delivery and callback * Captured nack body (up to 8 KB) # Authentication Source: https://docs.posthook.io/essentials/authentication How to authenticate your API requests The Posthook API uses API keys to authenticate requests. You can view and manage your API keys in the **Settings** page of your project in the [Dashboard](https://posthook.io/app/home). ## X-API-Key Header Authenticate requests using the `X-API-Key` header. ```bash theme={null} X-API-Key: ``` **Project Scoping**: Each API Key is tied to a **specific project**. All API requests are automatically scoped to that project. You cannot access resources from Project B while using an API Key from Project A. Although some APIs use `Authorization: Bearer `, Posthook expects the key directly in the `X-API-Key` header. ## Example Request ```bash theme={null} curl "https://api.posthook.io/v1/hooks" \ -H "X-API-Key: phk_test_..." \ -H "Content-Type: application/json" ``` ## Using the SDK The official SDKs handle authentication automatically. Pass your API key when creating the client — no need to set headers manually. ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; const posthook = new Posthook('phk_your_api_key'); // All requests are automatically authenticated const hook = await posthook.hooks.schedule({ path: '/webhooks/remind', postIn: '5m', data: { userId: '123' } }); ``` ```python Python theme={null} from posthook import Posthook client = Posthook("phk_your_api_key") # All requests are automatically authenticated hook = client.hooks.schedule( "/webhooks/remind", post_in="5m", data={"userId": "123"} ) ``` ```go Go theme={null} import "github.com/posthook/posthook-go" client, err := posthook.NewClient("phk_your_api_key") // All requests are automatically authenticated hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{ Path: "/webhooks/remind", PostIn: "5m", Data: map[string]string{"userId": "123"}, }) ``` You can also set the `POSTHOOK_API_KEY` environment variable instead of passing the key directly. Your API keys carry significant privileges, so keep them secure. Do not share your secret API keys in publicly accessible areas such as GitHub, client-side code, etc. # Config-as-Code Source: https://docs.posthook.io/essentials/config-as-code Manage project settings declaratively with posthook.toml Manage project settings declaratively with a `posthook.toml` file. The CLI syncs this file with your remote project configuration, enabling version control and repeatable environments. For detailed documentation on each config section, see [Sequences](/essentials/sequences#config-as-code-recommended), [Project Settings](/essentials/project-settings), and [Scheduling](/essentials/scheduling). ## Init Fetches the current project config from the API and writes it to `posthook.toml` in the current directory. ```bash theme={null} npx posthook init ``` The CLI prompts for your API key (if not already stored) and saves it to `~/.posthook/config.json`. ## Pull Downloads the remote config and overwrites the local TOML file. Shows a diff and prompts for confirmation before overwriting. ```bash theme={null} npx posthook pull ``` **Options**: * `--non-interactive`: Skip confirmation prompts. ## Apply Pushes local TOML changes to the remote project. Shows a diff and prompts for confirmation before applying. ```bash theme={null} npx posthook apply ``` **Options**: * `--dry-run`: Show changes without applying. * `--non-interactive`: Skip all confirmation prompts. The CLI prompts for additional confirmation when: * **Reducing retention** (permanently deletes older data) * **Deleting sequences** (lists affected sequences by name) Apply also verifies that the API key's project matches the TOML's `project` field, preventing accidental cross-project changes. ## Diff Shows pending local changes without applying them. ```bash theme={null} npx posthook diff ``` ## Status Displays project info, domain, masked API key, and signing key for the current config. No API call is made. ```bash theme={null} npx posthook status ``` ## Validate Checks TOML syntax and schema locally. No API call is made. ```bash theme={null} npx posthook validate ``` ## TOML format The config file covers project settings, retry behavior, notifications, retention, and sequences. ```toml theme={null} project = "aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee" name = "my-app" domain = "api.myapp.com" [retry] attempts = 5 strategy = "exponential" delay_seconds = 15 backoff_factor = 2.0 max_delay_seconds = 3600 jitter = true [notifications] channels = ["oncall-slack"] group_wait_seconds = 120 notify_on_recovery = true [retention] days = 60 [[sequences]] name = "daily-health-check" [sequences.schedule] frequency = "daily" timezone = "America/Chicago" time = { hour = 6, minute = 0 } [sequences.steps.check-api] path = "/webhooks/health/api" data = { service = "api" } [sequences.steps.check-api.retry_override] min_retries = 5 delay_seconds = 10 strategy = "exponential" max_delay_seconds = 300 [sequences.steps.report] path = "/webhooks/health/report" depends_on = ["check-api"] ``` The `project` field (UUID) is required. All other sections are optional and default to your project's current settings. ## Multi-environment configs Use the `--config` flag (or `-c`) to create named config files for different environments. ```bash theme={null} npx posthook init -c staging # Creates posthook.staging.toml npx posthook apply -c staging # Applies posthook.staging.toml npx posthook diff -c dev # Diffs posthook.dev.toml ``` Each named config can point to a different project. Credentials are stored per-project in `~/.posthook/config.json`. ## Common flags | Flag | Description | | --------------------- | ------------------------------------------------ | | `-k, --api-key ` | Override the stored API key | | `-c, --config ` | Use a named config file (`posthook..toml`) | These flags apply to `init`, `pull`, `apply`, and `diff`. The `status` and `validate` commands only accept `-c, --config`. # How Posthook Works Source: https://docs.posthook.io/essentials/how-it-works Understand the hook lifecycle from scheduling to delivery ## Hook Lifecycle You call `POST /hooks` with a path, payload, and a time. The hook is immediately committed to a **durable message queue** before the API returns `201`. This guarantees your hook is safely accepted even during traffic spikes or database maintenance. The hook is pulled off the queue and written to a high-availability **PostgreSQL** cluster for long-term tracking and dashboard visibility. The hook remains dormant until its scheduled delivery time. Due hooks are atomically claimed and published to a durable outgoing queue. The hook is picked up from the outgoing queue. Before making the HTTP request, a **distributed lock** is acquired to minimize duplicate deliveries. The request includes your payload, hook metadata, and [cryptographic signatures](/essentials/security). * **2xx** → `completed`. Delivery is done. * **Non-2xx or timeout** → `retry`. Retries follow your [retry configuration](/essentials/scheduling#per-hook-retry-override). After exhausting all attempts → `failed` and a [notification](/essentials/project-settings#notifications) is sent. ## Hook Statuses The API exposes four statuses. Internal states (like dispatching or in-flight) are abstracted into `pending`. | Status | Meaning | | ----------- | ---------------------------------------------------------------- | | `pending` | Scheduled and waiting for delivery time, or currently in-flight. | | `retry` | Delivery failed. Waiting for the next retry attempt. | | `completed` | Delivered successfully (2xx response). Terminal. | | `failed` | All retries exhausted. Terminal. | ``` pending → completed (first attempt succeeds) pending → retry → completed (succeeds after retries) pending → retry → failed (all retries exhausted) ``` ## Multi-Zone Redundancy All infrastructure runs across **multiple availability zones** within a region: * **Compute** — Kubernetes cluster with automatic rescheduling on zone failure. * **Messaging** — Google Cloud Pub/Sub, a global service with no single zone dependency. * **Storage** — PostgreSQL with synchronous replication and automatic failover. * **Caching and Locking** — Valkey with replicas across zones for deduplication availability. ## What Gets Sent When Posthook delivers a hook, it sends an HTTP POST with your payload, hook metadata, and signature headers. See [Receiving Webhooks](/essentials/receiving-webhooks) for the full request format and framework-specific handler examples. # Local Development Source: https://docs.posthook.io/essentials/local-development Receive webhooks on localhost without tunnels or public URLs The Posthook CLI helps you develop and test your webhooks locally. Receive hooks directly on your machine — no tunneling tools needed. ## Installation No global installation required. Run the CLI on-demand with `npx`. ```bash theme={null} npx posthook ``` ## Login Prompts for your API Key (found in Dashboard Settings) and stores it in `~/.posthook/config.json`. ```bash theme={null} npx posthook login ``` ## Logout Clears stored API keys from `~/.posthook/config.json`. ```bash theme={null} npx posthook logout ``` ## Listen Connects to Posthook via WebSocket and forwards incoming webhooks to a local URL. ```bash theme={null} npx posthook listen --forward ``` **Options**: * `-f, --forward `: Local URL to forward requests to (e.g., `http://localhost:3000`). * `-k, --api-key `: Manually specify an API key instead of using the logged-in user's key. * `-v, --verbose`: Show full headers and body for forwarded requests. * `--debug `: Write debug log to a file (payloads are redacted). **Example**: ```bash theme={null} npx posthook listen --forward http://localhost:8080 ``` ### Forward mode When `--forward` is specified, the CLI forwards each incoming hook as an HTTP POST to the given URL. The request is identical to the one Posthook would make via HTTPS in production — same payload, same headers (`Posthook-Id`, `Posthook-Timestamp`, `Posthook-Signature`, `X-Ph-Signature`, `Authorization`) — so your handler code works the same locally as it does in production. The CLI reports the response status from your local server and automatically accepts or fails the hook based on the HTTP status code (2xx = accept, anything else = fail). Hooks are processed in parallel with no user interaction required. #### Async hooks [Async hooks](/essentials/async-hooks) work in forward mode with no extra configuration. When async hooks are enabled on your project, the CLI includes `Posthook-Ack-URL` and `Posthook-Nack-URL` headers in the forwarded request. If your local endpoint returns **202**, the CLI relays the `Posthook-Async-Timeout` response header back to Posthook and waits for your code to call back via the ack/nack URLs. ### Interactive mode Without `--forward`, the CLI enters interactive mode. Each hook is presented one at a time with its ID, path, timestamps, payload, and attempt history. When a hook arrives you can: * **Accept (A)**: Mark as delivered successfully. * **Fail (F)**: Reject and trigger retry. * **Skip (S)**: Skip without responding (the server will time out and retry). # Project Settings Source: https://docs.posthook.io/essentials/project-settings Configure retries, notifications, and delivery settings You can configure your project's behavior in the **Settings** tab of the dashboard, or declaratively via [`posthook.toml`](/essentials/config-as-code). ## General * **Name**: The display name of your project. * **Domain**: The base domain for your webhook endpoints. Optional for local development projects without a domain. When set, you can use relative paths when scheduling hooks. * **Custom Authorization Header**: An optional value (e.g., `Bearer my-token`) that Posthook will include in the `Authorization` header of every request. ## Retries Control how Posthook handles failures (non-200 responses or timeouts). * **Max Retries**: Number of retry attempts (1–15, plan-dependent). * **Retry Delay**: Base delay between attempts (5–60 seconds). * **Strategy**: `fixed` (constant delay) or `exponential` (increasing delay). Available strategies may vary by plan. * **Backoff Factor**: Multiplier for each retry when using exponential strategy (e.g., `2.0`). * **Max Delay**: Upper bound on delay between retries when using exponential strategy. * **Jitter**: Add randomness to retry timing to avoid thundering herd (exponential only). Use a shorter delay for time-sensitive hooks, and a longer delay if your endpoint needs time to recover. **In `posthook.toml`:** ```toml theme={null} [retry] attempts = 5 strategy = "exponential" # "fixed" or "exponential" delay_seconds = 15 backoff_factor = 2.0 # exponential only max_delay_seconds = 3600 # exponential only jitter = true # exponential only ``` ## Notifications Configure when and how Posthook notifies you about delivery failures. ### Channels Notifications can be sent via **Email**, **Slack**, or **Custom Webhooks**. All channels receive the same alerts. Available channels may vary by plan. ### Timing * **Group Wait** (`group_wait_seconds`): When a failure occurs, wait this long before sending a notification so multiple failures get grouped into one alert. Default: `120` seconds. * **Follow-Up Interval** (`follow_up_interval_seconds`): Minimum time between notifications for the same project. Follow-ups use exponential backoff (e.g., 15m → 30m → 1h → ... → 24h cap). Default: `900` seconds. Default timing values may vary by plan. ### Anomaly Detection Posthook tracks historical failure rates per endpoint and compares them against a recent window. If failure rates spike above the baseline, an alert is triggered at the project level. * **Excess Failure Threshold** (`excess_failure_threshold`): Number of failures above baseline before alerting. `0` means alert on any excess. Default: `0`. * **Baseline Window** (`baseline_window_mins`): Historical window for calculating the normal failure rate. Default: `60` minutes. * **Recent Window** (`recent_window_mins`): Window for detecting spikes against the baseline. Default: `5` minutes. * **Min Deliveries** (`min_deliveries`): Minimum deliveries needed in the baseline window before anomaly detection kicks in. Prevents false alarms on low-traffic endpoints. Default: `5`. ### Recovery * **Notify on Recovery** (`notify_on_recovery`): Send an alert when a failing endpoint returns to normal. Default: `true`. ### Example config ```toml theme={null} [notifications] channels = ["oncall-slack", "eng-email"] group_wait_seconds = 120 follow_up_interval_seconds = 900 notify_on_recovery = true excess_failure_threshold = 0 baseline_window_mins = 60 recent_window_mins = 5 min_deliveries = 5 ``` ## Retention Control how long Posthook stores terminal hooks (`completed` and `failed`). Pending and in-retry hooks are retained until they reach a terminal state, then the retention clock starts. After the retention period, records are permanently deleted. * **Maximum**: Determined by your plan (Free: 7, Launch: 30, Growth: 60, Scale: 90 days). * **Minimum**: 7 days. You can lower retention to any value between 7 days and your plan's maximum. Reducing retention permanently deletes records older than the new limit. This cannot be undone. **In `posthook.toml`:** ```toml theme={null} [retention] days = 60 ``` ## WebSocket Delivery (Beta) WebSocket delivery allows you to receive webhooks on your local machine without exposing a public IP or using tools like ngrok. 1. Toggle **WebSocket Delivery** on. 2. Run the CLI command provided: ```bash theme={null} npx posthook listen --api-key $API_KEY --forward http://localhost:3000 ``` 3. Posthook will accept your scheduled hooks and immediately push them down the WebSocket connection to your CLI, which forwards them to your localhost. This is intended for development only. For production, we recommend using standard HTTPS delivery. ## Async Hooks (Beta) Async hooks let your endpoint return **HTTP 202** to acknowledge receipt, then call back to Posthook when processing completes. This removes the 10-second delivery timeout for long-running tasks. Toggle **Async Hooks** on in the dashboard to include `Posthook-Ack-URL` and `Posthook-Nack-URL` callback headers with every delivery. This setting is not yet available in `posthook.toml`. See the [Async Hooks guide](/essentials/async-hooks) for the full implementation walkthrough. ## Applying changes After editing `posthook.toml`, preview and deploy: ```bash theme={null} npx posthook diff # See what will change npx posthook apply # Deploy to Posthook ``` See the [Config-as-Code guide](/essentials/config-as-code) for the full workflow. # Receiving Webhooks Source: https://docs.posthook.io/essentials/receiving-webhooks Handle webhook deliveries from Posthook in your application When the scheduled time arrives, Posthook sends an HTTP POST request to your endpoint. This page covers the request format, how to handle deliveries, and production best practices. The destination URL combines your **Project Domain** (configured in [settings](/essentials/project-settings)) with the **Path** specified when scheduling the hook. > `api.myapp.com` (Domain) + `/webhooks/remind` (Path) = `https://api.myapp.com/webhooks/remind` (HTTPS is enforced) ## The Request Posthook sends a `POST` request containing your data along with scheduling metadata. ```json theme={null} { "id": "e5405623-2c1c-460e-9737-c884f7f59035", "path": "/webhooks/remind", "postAt": "2025-01-01T12:00:00Z", "postedAt": "2025-01-01T12:00:00.123Z", "data": { "user_id": "usr_123", "reminder_id": "rem_456" }, "createdAt": "2024-12-31T12:00:00Z", "updatedAt": "2024-12-31T12:00:00Z" } ``` ### Headers | Header | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `Posthook-Id` | Unique hook ID (e.g. `e5405623-2c1c-460e-9737-c884f7f59035`) | | `Posthook-Timestamp` | Unix timestamp (seconds) of when the hook was posted | | `Posthook-Signature` | One or more `v1,` signatures, space-separated | | `Posthook-Ack-URL` | Async ack callback URL ([async hooks](/essentials/async-hooks)) | | `Posthook-Nack-URL` | Async nack callback URL ([async hooks](/essentials/async-hooks)) | | `X-Ph-Signature` | Legacy HMAC-SHA256 signature (hex-encoded). See [Legacy Header](/essentials/security#legacy-header-x-ph-signature). | | `User-Agent` | `Posthook/1.0` | ## Framework Examples Complete webhook handlers showing how to receive a delivery, verify the signature, and return a response. Each example uses the official SDK for [signature verification](/essentials/security). ```javascript Express.js theme={null} import express from 'express'; import { Signatures, SignatureVerificationError } from '@posthook/node'; const app = express(); app.use(express.json()); const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); app.post('/webhooks/posthook', async (req, res) => { try { const delivery = signatures.parseDelivery(req.body, req.headers); // Your business logic const { user_id, reminder_id } = delivery.data; await sendReminder(user_id, reminder_id); res.status(200).send('OK'); } catch (err) { if (err instanceof SignatureVerificationError) { return res.status(401).json({ error: err.message }); } throw err; } }); ``` ```javascript Next.js (App Router) theme={null} import { Signatures, SignatureVerificationError } from '@posthook/node'; import { NextResponse } from 'next/server'; const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); export async function POST(request) { const body = await request.text(); const headers = Object.fromEntries(request.headers); try { const delivery = signatures.parseDelivery(body, headers); // Your business logic const { user_id, reminder_id } = delivery.data; await sendReminder(user_id, reminder_id); return NextResponse.json({ ok: true }); } catch (err) { if (err instanceof SignatureVerificationError) { return NextResponse.json({ error: err.message }, { status: 401 }); } throw err; } } ``` ```python Flask theme={null} from flask import Flask, request, jsonify from posthook import SignaturesService, SignatureVerificationError app = Flask(__name__) signatures = SignaturesService(os.environ["POSTHOOK_SIGNING_KEY"]) @app.route('/webhooks/posthook', methods=['POST']) def handle_webhook(): try: delivery = signatures.parse_delivery( request.get_data(), dict(request.headers) ) # Your business logic send_reminder(delivery.data["user_id"], delivery.data["reminder_id"]) return "OK", 200 except SignatureVerificationError as err: return jsonify(error=err.message), 401 ``` ```python FastAPI theme={null} from fastapi import FastAPI, Request, HTTPException from posthook import SignaturesService, SignatureVerificationError app = FastAPI() signatures = SignaturesService(os.environ["POSTHOOK_SIGNING_KEY"]) @app.post('/webhooks/posthook') async def handle_webhook(request: Request): body = await request.body() headers = dict(request.headers) try: delivery = signatures.parse_delivery(body, headers) # Your business logic await send_reminder(delivery.data["user_id"], delivery.data["reminder_id"]) return {"ok": True} except SignatureVerificationError as err: raise HTTPException(status_code=401, detail=err.message) ``` ```go Go (net/http) theme={null} package main import ( "io" "net/http" "os" posthook "github.com/posthook/posthook-go" ) var signatures, _ = posthook.NewSignatures(os.Getenv("POSTHOOK_SIGNING_KEY")) func handleWebhook(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivery, err := signatures.ParseDelivery(body, r.Header) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // delivery.Data (json.RawMessage) contains your verified payload // delivery.HookID, delivery.Path, delivery.PostedAt also available w.WriteHeader(http.StatusOK) } ``` For a deep dive on how signatures work, key rotation, and replay protection, see the [Security](/essentials/security) page. ## Retries Posthook guarantees **at-least-once delivery**. If your endpoint returns a non-2xx status code or times out (10-second limit), Posthook retries the delivery based on your project's retry configuration. You can configure: * **Max Retries**: Up to 15 attempts (plan-dependent). * **Retry Delay**: 5 to 60 seconds between attempts. See [Project Settings](/essentials/project-settings) to configure defaults for your project. ### Per-hook retry override Individual hooks can override your project's retry settings via the `retryOverride` field. ```json theme={null} { "path": "/webhooks/process-payment", "postAt": "2026-03-14T18:00:00Z", "data": { "order_id": "ord_789" }, "retryOverride": { "minRetries": 15, "delaySecs": 5, "strategy": "fixed" } } ``` Choose `fixed` for constant delay or `exponential` for increasing delays with `backoffFactor`, `maxDelaySecs`, and `jitter`. See [Scheduling: Per-hook retry override](/essentials/scheduling#per-hook-retry-override) for the full field reference. ## Idempotency Because at-least-once delivery can produce rare duplicates under failure conditions, your hook handler should be **idempotent**. The right approach varies by use case. Some operations are naturally idempotent (e.g., setting a value in a database). Others need explicit deduplication. For cases that need it, include a unique ID in your payload (e.g., `idempotency_key`, `event_id`, or your own business logic ID like `reminder_id`). You can also use the `id` field from the hook delivery itself as your idempotency key. ```json theme={null} { "path": "/webhooks/remind", "data": { "reminder_id": "rem_559201", "user_id": "usr_201" } } ``` When your handler receives the request, use a transaction to atomically claim the ID and do the work. If the work fails, the transaction rolls back and the retry can try again. ```javascript Node.js theme={null} // IDEMPOTENCY: claim the ID and do the work in one transaction app.post('/webhooks/posthook/remind', async (req, res) => { const { reminder_id, user_id } = req.body.data; const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( 'INSERT INTO processed_hooks (idempotency_key) VALUES ($1)', [reminder_id] ); await sendReminder(user_id, reminder_id); await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); if (err.code === '23505') { // unique_violation: already processed return res.status(200).send('OK'); } throw err; } finally { client.release(); } res.status(200).send('OK'); }); ``` ```python Python theme={null} # IDEMPOTENCY: claim the ID and do the work in one transaction @app.route('/webhooks/posthook/remind', methods=['POST']) def handle_remind(): data = request.json['data'] reminder_id = data['reminder_id'] try: db.execute( 'INSERT INTO processed_hooks (idempotency_key) VALUES (%s)', (reminder_id,) ) send_reminder(data['user_id'], reminder_id) db.commit() except IntegrityError: db.rollback() return 'OK', 200 return 'OK', 200 ``` ```go Go theme={null} // IDEMPOTENCY: claim the ID and do the work in one transaction func handleRemind(w http.ResponseWriter, r *http.Request) { var hook struct { Data struct { ReminderID string `json:"reminder_id"` UserID string `json:"user_id"` } `json:"data"` } json.NewDecoder(r.Body).Decode(&hook) tx, err := db.Begin() if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } defer tx.Rollback() _, err = tx.Exec( "INSERT INTO processed_hooks (idempotency_key) VALUES ($1)", hook.Data.ReminderID, ) if err != nil { // Duplicate: already processed w.WriteHeader(http.StatusOK) return } sendReminder(hook.Data.UserID, hook.Data.ReminderID) if err := tx.Commit(); err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } ``` If the work fails (e.g., `sendReminder` errors), the transaction rolls back, the idempotency key is not recorded, and the hook is retried. If you don't use a relational database, you can achieve the same with Redis `SET key NX EX ttl` combined with a cleanup step on failure. The key is that the lock should only persist once the work succeeds. For more background on at-least-once delivery and idempotent design, see Twilio's [Exactly-once delivery](https://www.twilio.com/en-us/blog/insights/exactly-once-delivery) and Stripe's [Designing with Idempotency](https://stripe.com/blog/idempotency). ## Handling Long-Running Tasks Posthook enforces a **10-second timeout** on webhook delivery. If your task takes longer than 10 seconds (e.g., generating a PDF, processing a video), your request will time out and be retried. You have two options: ### Option 1: Async Hooks (Recommended) Enable [Async Hooks](/essentials/async-hooks) on your project and return **HTTP 202**. Posthook will wait for your ack/nack callback instead of timing out. This gives you built-in timeout management, retry on failure, and visibility into async processing status in the dashboard. ### Option 2: Queue Locally If you prefer to manage your own job queue: 1. Receive the webhook. 2. Push the job to an internal background queue (e.g., Redis, SQS, BullMQ). 3. **Return `200 OK` immediately** to Posthook. 4. Process the job asynchronously in your worker. If you need to track the completion of these long-running tasks (especially if they are 3rd-party APIs), check out the [Recursive Polling](/patterns/polling) pattern. # Scheduling Source: https://docs.posthook.io/essentials/scheduling Schedule hooks in UTC, local time, or relative delay Posthook supports three ways to schedule a hook: a UTC timestamp (`postAt`), a local datetime with a timezone (`postAtLocal` + `timezone`), or a relative delay from now (`postIn`). ## UTC scheduling with `postAt` Provide an [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) timestamp in UTC: ```typescript TypeScript theme={null} const hook = await posthook.hooks.schedule({ path: '/webhooks/reminder', postAt: '2026-03-14T18:00:00Z', data: { user_id: 'usr_123' } }); ``` ```python Python theme={null} hook = client.hooks.schedule( "/webhooks/reminder", post_at="2026-03-14T18:00:00Z", data={"user_id": "usr_123"} ) ``` ```go Go theme={null} hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{ Path: "/webhooks/reminder", PostAt: time.Date(2026, 3, 14, 18, 0, 0, 0, time.UTC), Data: map[string]string{"user_id": "usr_123"}, }) ``` ```bash curl theme={null} curl -X POST https://api.posthook.io/v1/hooks \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "path": "/webhooks/reminder", "postAt": "2026-03-14T18:00:00Z", "data": { "user_id": "usr_123" } }' ``` | Field | Description | | -------- | ----------------------------------------------------------------------------------------------------------- | | `postAt` | RFC 3339 timestamp in UTC (e.g., `2026-03-14T18:00:00Z`). Timestamps in the past are delivered immediately. | ## Local time scheduling with `postAtLocal` + `timezone` When you need delivery at a specific clock time in a specific timezone, use `postAtLocal` and `timezone` together. Posthook converts the local time to UTC at scheduling time, accounting for DST. ```typescript TypeScript theme={null} const hook = await posthook.hooks.schedule({ path: '/webhooks/reminder', postAtLocal: '2026-03-15T09:00:00', timezone: 'America/New_York', data: { user_id: 'usr_123' } }); ``` ```python Python theme={null} hook = client.hooks.schedule( "/webhooks/reminder", post_at_local="2026-03-15T09:00:00", timezone="America/New_York", data={"user_id": "usr_123"} ) ``` ```go Go theme={null} hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{ Path: "/webhooks/reminder", PostAtLocal: "2026-03-15T09:00:00", Timezone: "America/New_York", Data: map[string]string{"user_id": "usr_123"}, }) ``` ```bash curl theme={null} curl -X POST https://api.posthook.io/v1/hooks \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "path": "/webhooks/reminder", "postAtLocal": "2026-03-15T09:00:00", "timezone": "America/New_York", "data": { "user_id": "usr_123" } }' ``` | Field | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `postAtLocal` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) local datetime without offset, in `YYYY-MM-DDTHH:MM:SS` format (e.g., `2026-03-15T13:00:00`). Do not include a `Z` suffix or UTC offset. | | `timezone` | [IANA Time Zone](https://www.iana.org/time-zones) identifier (e.g., `America/New_York`, `Europe/London`, `Asia/Tokyo`). | ## Relative delay with `postIn` Schedule a hook relative to now using a duration string. Posthook resolves the delay to a UTC timestamp at scheduling time. ```typescript TypeScript theme={null} const hook = await posthook.hooks.schedule({ path: '/webhooks/process', postIn: '1h30m', data: { job_id: 'job_789' } }); ``` ```python Python theme={null} hook = client.hooks.schedule( "/webhooks/process", post_in="1h30m", data={"job_id": "job_789"} ) ``` ```go Go theme={null} hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{ Path: "/webhooks/process", PostIn: "1h30m", Data: map[string]string{"job_id": "job_789"}, }) ``` ```bash curl theme={null} curl -X POST https://api.posthook.io/v1/hooks \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "path": "/webhooks/process", "postIn": "1h30m", "data": { "job_id": "job_789" } }' ``` | Field | Description | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `postIn` | Duration string specifying how far in the future to deliver. Uses unit suffixes: `d` (days), `h` (hours), `m` (minutes), `s` (seconds). Compound values like `1d2h30m` are supported. Minimum 1 second, maximum 365 days. | **Examples**: `30s`, `5m`, `2h`, `7d`, `1h30m`, `1d2h30m45s` ### Validation rules * Provide exactly one of `postAt`, `postAtLocal`, or `postIn`. * `timezone` is required with `postAtLocal` and forbidden with `postAt` or `postIn`. ## DST behavior When using `postAtLocal` + `timezone`, Posthook resolves ambiguous times automatically: * **Spring forward (gap)**: If the local time falls in a skipped hour, delivery is shifted forward to the first valid moment after the gap (e.g., `02:30` becomes `03:00`). * **Fall back (fold)**: If the local time occurs twice, the earlier occurrence is used. ## Per-hook retry override You can override your project's default retry settings for individual hooks by including a `retryOverride` object. If omitted, the hook uses your project's retry configuration. ### Fields | Field | Type | Required | Description | | --------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------- | | `minRetries` | integer | Yes | Number of retry attempts (1–15) | | `delaySecs` | integer | Yes | Base delay between retries in seconds (5–60) | | `strategy` | string | Yes | `fixed` (constant delay) or `exponential` (increasing delay). Available strategies may vary by plan. | | `backoffFactor` | number | No | Multiplier for exponential backoff (1.1–4.0). Defaults to `2.0` if omitted. | | `maxDelaySecs` | integer | No | Maximum delay cap in seconds (60–3600). Required when strategy is `exponential`. | | `jitter` | boolean | No | Add random jitter to retry delays to avoid thundering herd | ### Example: aggressive retries Retry up to 15 times with a 5-second fixed delay: ```typescript TypeScript theme={null} const hook = await posthook.hooks.schedule({ path: '/webhooks/process-payment', postAt: '2026-03-14T18:00:00Z', data: { order_id: 'ord_789' }, retryOverride: { minRetries: 15, delaySecs: 5, strategy: 'fixed' } }); ``` ```python Python theme={null} from posthook import HookRetryOverride hook = client.hooks.schedule( "/webhooks/process-payment", post_at="2026-03-14T18:00:00Z", data={"order_id": "ord_789"}, retry_override=HookRetryOverride( min_retries=15, delay_secs=5, strategy="fixed" ) ) ``` ```go Go theme={null} hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{ Path: "/webhooks/process-payment", PostAt: time.Date(2026, 3, 14, 18, 0, 0, 0, time.UTC), Data: map[string]string{"order_id": "ord_789"}, RetryOverride: &posthook.HookRetryOverride{ MinRetries: 15, DelaySecs: 5, Strategy: posthook.StrategyFixed, }, }) ``` ```bash curl theme={null} curl -X POST https://api.posthook.io/v1/hooks \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "path": "/webhooks/process-payment", "postAt": "2026-03-14T18:00:00Z", "data": { "order_id": "ord_789" }, "retryOverride": { "minRetries": 15, "delaySecs": 5, "strategy": "fixed" } }' ``` ### Example: exponential backoff Start with a 10-second delay, doubling up to 5 minutes: ```typescript TypeScript theme={null} const hook = await posthook.hooks.schedule({ path: '/webhooks/sync-data', postAtLocal: '2026-03-15T09:00:00', timezone: 'America/Chicago', data: { sync_id: 'sync_456' }, retryOverride: { minRetries: 8, delaySecs: 10, strategy: 'exponential', backoffFactor: 2.0, maxDelaySecs: 300, jitter: true } }); ``` ```python Python theme={null} from posthook import HookRetryOverride hook = client.hooks.schedule( "/webhooks/sync-data", post_at_local="2026-03-15T09:00:00", timezone="America/Chicago", data={"sync_id": "sync_456"}, retry_override=HookRetryOverride( min_retries=8, delay_secs=10, strategy="exponential", backoff_factor=2.0, max_delay_secs=300, jitter=True ) ) ``` ```go Go theme={null} hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{ Path: "/webhooks/sync-data", PostAtLocal: "2026-03-15T09:00:00", Timezone: "America/Chicago", Data: map[string]string{"sync_id": "sync_456"}, RetryOverride: &posthook.HookRetryOverride{ MinRetries: 8, DelaySecs: 10, Strategy: posthook.StrategyExponential, BackoffFactor: 2.0, MaxDelaySecs: 300, Jitter: true, }, }) ``` ```bash curl theme={null} curl -X POST https://api.posthook.io/v1/hooks \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "path": "/webhooks/sync-data", "postAtLocal": "2026-03-15T09:00:00", "timezone": "America/Chicago", "data": { "sync_id": "sync_456" }, "retryOverride": { "minRetries": 8, "delaySecs": 10, "strategy": "exponential", "backoffFactor": 2.0, "maxDelaySecs": 300, "jitter": true } }' ``` ## Usage quota Each plan includes a monthly hook quota — the number of hooks you can schedule per calendar month. Every `POST /hooks` call counts toward the quota, regardless of when the hook is scheduled to deliver. The quota resets at the start of each calendar month. When you hit the limit, `POST /hooks` returns `429 Too Many Requests` until the quota resets. Posthook sends email alerts at 75%, 100%, and 150% of your quota so you can adjust usage or upgrade before reaching the limit. Quota limits may vary by plan. ### Quota headers Every `POST /hooks` response includes headers showing your current plan usage: | Header | Description | | ------------------------------ | --------------------------------------------------- | | `Posthook-HookQuota-Limit` | Maximum hooks allowed in the current calendar month | | `Posthook-HookQuota-Usage` | Hooks used so far in the current calendar month | | `Posthook-HookQuota-Remaining` | Hooks remaining in the current calendar month | | `Posthook-HookQuota-Resets-At` | RFC 3339 timestamp when the quota resets | Use these headers to monitor usage programmatically and avoid hitting plan limits. # Security Source: https://docs.posthook.io/essentials/security Securing your webhook integration Security is critical when exposing webhook endpoints. Posthook provides tools to ensure that your communication is secure and authenticated. ## Keys ### API Key The **API Key** is used to authenticate *your* requests to the Posthook API (e.g., when scheduling a hook via `POST /hooks`). This key grants full access to your project, so keep it secret. * **Usage**: Set in the `X-API-Key` header. * **Exposure**: Never expose this in client-side code (browsers, mobile apps). Store it securely in your backend environment variables. ### Signing Key The **Signing Key** is used to verify that incoming hooks to your endpoint actually came from Posthook. * **Usage**: Used to compute the `Posthook-Signature` and `X-Ph-Signature` headers included with every delivery. * **Rotation**: Posthook supports zero-downtime key rotation. During the grace period, `Posthook-Signature` includes signatures for both the active and retiring keys. See [Key Rotation](#key-rotation). * **Exposure**: This key should also be kept secret on your backend. ## Replay Attacks To protect against replay attacks, where an attacker intercepts and resends a valid payload, verify the timestamp included with the delivery. You can use either: * **`Posthook-Timestamp` header** (recommended) — Unix timestamp in seconds. This value is also part of the signed payload, so it cannot be tampered with. * **`postedAt` body field** — RFC 3339 timestamp. ### Best Practice Reject requests where the timestamp is too far from the current time (e.g., 5 minutes). ```javascript theme={null} const TOLERANCE = 5 * 60; // 5 minutes in seconds const timestamp = parseInt(req.headers['posthook-timestamp'], 10); const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > TOLERANCE) { return res.status(401).send('Request too old'); } ``` ## SDK Verification (Recommended) The official SDKs provide a `parseDelivery()` method that handles signature verification, timestamp validation, and replay protection in a single call. You will need your **Signing Key**, found in **Project Settings** in the [Dashboard](https://posthook.io/app/home). ```javascript Node.js theme={null} import { Signatures, SignatureVerificationError } from '@posthook/node'; const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); app.post('/webhooks/posthook/remind', async (req, res) => { try { const delivery = signatures.parseDelivery(req.body, req.headers); // delivery.data is your verified payload await sendReminder(delivery.data.userId); res.status(200).send('OK'); } catch (err) { if (err instanceof SignatureVerificationError) { return res.status(401).json({ error: err.message, code: err.code }); } throw err; } }); ``` ```python Python theme={null} from posthook import SignaturesService, SignatureVerificationError signatures = SignaturesService(os.environ["POSTHOOK_SIGNING_KEY"]) @app.route('/webhooks/posthook/remind', methods=['POST']) def handle_remind(): try: delivery = signatures.parse_delivery( request.get_data(), dict(request.headers) ) # delivery.data is your verified payload send_reminder(delivery.data["userId"]) return "OK", 200 except SignatureVerificationError as err: return {"error": err.message, "code": err.code}, 401 ``` ```go Go theme={null} import posthook "github.com/posthook/posthook-go" signatures, _ := posthook.NewSignatures(os.Getenv("POSTHOOK_SIGNING_KEY")) func handleRemind(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivery, err := signatures.ParseDelivery(body, r.Header) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // delivery.Data is your verified payload w.WriteHeader(http.StatusOK) } ``` `parseDelivery()` automatically rejects requests older than 5 minutes (configurable via `tolerance` option). No need to implement replay protection manually. ## How It Works Under the hood, the SDK verifies the `Posthook-Signature` header using HMAC-SHA256. Every webhook delivery includes three headers: | Header | Example | | -------------------- | -------------------------------------- | | `Posthook-Id` | `e5405623-2c1c-460e-9737-c884f7f59035` | | `Posthook-Timestamp` | `1700000000` | | `Posthook-Signature` | `v1,abc123 v1,def456` | The signature algorithm: 1. Build the signed payload: `{Posthook-Timestamp}.{raw_body}` (dot-separated). 2. Compute HMAC-SHA256 using your project's signing key. 3. Hex-encode the result (lowercase, 64 characters). 4. Prefix with `v1,`. During [key rotation](#key-rotation), `Posthook-Signature` contains **two** space-separated signatures (one per key). The SDK accepts the delivery if **either** signature matches. ## Key Rotation Posthook supports zero-downtime key rotation through a grace period: 1. **Before rotation** — `Posthook-Signature` has one `v1,` entry using the active signing key. 2. **After rotation (grace period)** — `Posthook-Signature` includes **two** `v1,` entries: one for the new active key and one for the retiring key. Accept the delivery if either matches. 3. **After grace period expires** — The retiring key is excluded. `Posthook-Signature` returns to a single `v1,` entry. 4. **Revoke (optional)** — You can revoke the retiring key early via the API or dashboard. The legacy `X-Ph-Signature` header switches to the new key **immediately** on rotation with no grace period. This is the primary reason to migrate to `Posthook-Signature`. ## Legacy Header (X-Ph-Signature) `X-Ph-Signature` is deprecated. Migrate to `Posthook-Signature` for dual-key support during key rotation. The SDK's `parseDelivery()` verifies `Posthook-Signature` automatically. `X-Ph-Signature` contains a hex-encoded HMAC-SHA256 of the **raw request body** using the active project signing key. **Key difference:** `X-Ph-Signature` uses only the active key. When you rotate your signing key, this header immediately switches to the new key — there is no grace period. If your consumer hasn't updated yet, verification will fail until it starts using the new key. # Sequences Source: https://docs.posthook.io/essentials/sequences Recurring workflows with dependency graphs and flexible scheduling Sequences let you define recurring workflows where multiple hooks fire on a schedule. Steps can declare dependencies, so downstream hooks only run after their prerequisites succeed. ## Overview A sequence is defined by: 1. **Steps**: The hooks to deliver, with optional dependencies. 2. **Schedule**: When and how often the sequence runs (interval or calendar). 3. **Start time**: When the first run begins. 4. **End time** (optional): When the sequence stops running. After this time, no further runs are produced. Sequences are created and managed in the [Dashboard](https://posthook.io/app/home). ## Steps Each step defines a hook to fire when the sequence runs. Steps are configured using YAML, where each key is a step name. ```yaml theme={null} syncData: path: /webhooks/posthook/sync data: source: "crm" cleanupOldRecords: path: /webhooks/posthook/cleanup notify: path: /webhooks/posthook/notify requires: - syncData - cleanupOldRecords ``` In the Dashboard, steps are defined using YAML. In `posthook.toml`, the same structure uses TOML syntax — see [Managing sequences](#config-as-code-recommended) below. ### Step fields | Field | Description | | ---------------- | ----------------------------------------------------------------------------------------------------------------------- | | `path` | Endpoint path for delivery (combined with your project domain) | | `data` | Optional JSON payload delivered with the hook | | `requires` | Optional list of step names that must succeed (2xx) before this step runs | | `retry_override` | Optional per-step retry settings (overrides project defaults). See [Per-step retry override](#per-step-retry-override). | ### Dependency graph Steps without `requires` run immediately when the sequence fires. Steps with `requires` wait until all listed dependencies complete successfully. In the example above: * **syncData** and **cleanupOldRecords** have no dependencies, so they start in parallel. * **notify** waits until both complete with a 2xx response. * If a dependency fails, dependent steps do not run for that execution. ### Per-step retry override By default, each step inherits your [project's retry settings](/essentials/config-as-code#toml-format). You can override retry behavior on individual steps using `retry_override` (TOML) or `retryOverride` (YAML/API). The fields are the same as the [per-hook retry override](/essentials/scheduling#per-hook-retry-override), using snake\_case in TOML. ```toml theme={null} [sequences.steps.critical-step] path = "/webhooks/payment/process" [sequences.steps.critical-step.retry_override] min_retries = 10 delay_seconds = 5 strategy = "exponential" backoff_factor = 2.0 max_delay_seconds = 300 jitter = true ``` Available retry strategies and limits depend on your plan. If a step override exceeds your plan's limits, the API returns a `400` error with upgrade guidance. ### Limits * Maximum **5 steps** per sequence (may vary by plan). * Maximum **64 KB** of data per step. ## Scheduling Each sequence uses exactly one scheduling type: **interval** or **calendar**. ### Interval scheduling Interval schedules repeat at a fixed cadence from the start time. | Unit | Allowed values | | --------- | ------------------------- | | `months` | 1, 2, 3, 6, 12 | | `days` | 1, 2, 3, 5, 7, 14, 21, 28 | | `hours` | 1, 2, 3, 4, 5, 6, 12 | | `minutes` | 5, 10, 15, 20, 30, 60, 90 | An interval of 7 days starting at `2026-03-01T09:00:00Z` fires on March 1, March 8, March 15, and so on. **In `posthook.toml`:** ```toml theme={null} [[sequences]] name = "weekly-sync" start_at = "2026-03-01T09:00:00Z" [sequences.interval] count = 7 unit = "days" ``` ### Calendar scheduling Calendar schedules run at specific clock times, on specific days, in a specific timezone. | Field | Description | | ----------- | -------------------------------------------------------------------------------- | | `frequency` | `hourly`, `daily`, `weekly`, or `monthly` | | `hour` | Hour of day (0–23) | | `minute` | Minute of hour (0–59) | | `timezone` | IANA timezone (e.g., `America/New_York`) | | `onDays` | Days of the week (e.g., `["monday", "wednesday", "friday"]`) | | `onDates` | Days of the month (e.g., `[1, 15]`, or negative values like `[-1]` for last day) | | `everyN` | Run every N intervals (e.g., every 2 hours, every 3 weeks) | ### Calendar field rules Not all field combinations are valid. | Field | Hourly | Daily | Weekly | Monthly | | ---------- | ------------------------ | --------- | --------- | --------- | | `hour` | Forbidden | Required | Required | Required | | `minute` | Optional (defaults to 0) | Required | Required | Required | | `timezone` | Required | Required | Required | Required | | `onDays` | Forbidden | Forbidden | Required | Forbidden | | `onDates` | Forbidden | Forbidden | Forbidden | Required | | `everyN` | Optional | Optional | Optional | Optional | **Examples**: * **Daily at 9:00 AM Eastern**: frequency `daily`, hour `9`, minute `0`, timezone `America/New_York` * **Weekly on Mon/Wed/Fri at 8:30 AM**: frequency `weekly`, hour `8`, minute `30`, timezone `America/Chicago`, onDays `["monday", "wednesday", "friday"]` * **Monthly on the 1st and 15th at noon**: frequency `monthly`, hour `12`, minute `0`, timezone `Europe/London`, onDates `[1, 15]` * **Every 2 hours at :00**: frequency `hourly`, timezone `UTC`, everyN `2` * **Every hour at :30**: frequency `hourly`, minute `30`, timezone `America/New_York` **In `posthook.toml`:** ```toml theme={null} [[sequences]] name = "daily-report" start_at = "2026-03-01T09:00:00Z" [sequences.schedule] frequency = "daily" timezone = "America/New_York" time = { hour = 9, minute = 0 } ``` `everyN` is anchored to the sequence's start time, not the most recent run. For example, `everyN: 2` with a weekly schedule starting on January 6 always fires on even-numbered weeks from that date, regardless of when the last run actually occurred. ## Short months and leap years If a monthly schedule is configured for a date that doesn't exist in a given month, that month is skipped. There is no clamping. * A schedule on the **30th** skips February (28 or 29 days) and runs in the other 11 months. * A schedule on the **31st** only runs in months with 31 days (January, March, May, July, August, October, December). To run on the last day of every month regardless of length, use a negative value: `onDates: [-1]`. Negative values count from the end of the month (`-1` = last day, `-2` = second to last, etc.). ## DST handling Calendar schedules handle Daylight Saving Time transitions automatically: * **Spring forward (gap)**: If the scheduled time falls in a skipped hour (e.g., 2:30 AM when clocks jump from 2:00 to 3:00), the run shifts forward to the first valid time after the gap. * **Fall back (fold)**: If the scheduled time occurs twice (e.g., 1:30 AM when clocks fall back from 2:00 to 1:00), the earlier occurrence is used. ## Managing sequences ### Config-as-code (recommended) Define sequences in `posthook.toml` alongside your application code. This gives you version control, code review, and repeatable deployments. **1. Initialize** — Pull your current project config into a TOML file: ```bash theme={null} npx posthook init ``` **2. Define a sequence** — Add a `[[sequences]]` block: ```toml theme={null} [[sequences]] name = "daily-health-check" start_at = "2026-03-01T06:00:00Z" [sequences.schedule] frequency = "daily" timezone = "America/Chicago" time = { hour = 6, minute = 0 } [sequences.steps.check-api] path = "/webhooks/health/api" data = { service = "api" } [sequences.steps.check-db] path = "/webhooks/health/db" data = { service = "database" } [sequences.steps.report] path = "/webhooks/health/report" depends_on = ["check-api", "check-db"] ``` In TOML config, step dependencies use `depends_on`. In the Dashboard and API, the same concept is called `requires`. **3. Preview and deploy**: ```bash theme={null} npx posthook validate # Check syntax locally npx posthook diff # Preview what will change npx posthook apply # Deploy to Posthook ``` Check `posthook.toml` into git. Use [multi-environment configs](/essentials/config-as-code#multi-environment-configs) (`posthook.staging.toml`, `posthook.prod.toml`) to manage sequences across environments. See the [Config-as-Code guide](/essentials/config-as-code) for the full list of commands and TOML schema. ### Dashboard You can also create and manage sequences in the [Dashboard](https://posthook.io/app/home). ## Usage patterns * [**Recurring Tasks**](/patterns/recurring-tasks): Schedule daily reports, health checks, or database maintenance. * [**Dead Letter Queue**](/patterns/dead-letter-queue): Automate periodic processing of failed hooks. * [**Auditing**](/patterns/auditing): Double-check important hooks ran. # Teams Source: https://docs.posthook.io/essentials/teams Multi-user access and role-based permissions Teams let multiple people access your Posthook account with role-based permissions. You can manage your team in the [Dashboard](https://posthook.io/app/home) under **Settings > Team**. ## Roles | Role | Description | | ---------- | ------------------------------------------------------------------------------------------------------ | | **Admin** | Full access. Can manage billing, team members, and all project settings. | | **Member** | Can create, view, and manage hooks, sequences, and projects. Cannot access billing or team management. | There must always be at least one admin. The last remaining admin cannot be removed or demoted. To transfer account ownership, invite the new owner as an admin. Once they accept, they can remove the original admin. ### Admin-only actions * Manage billing and subscription * Invite, remove, or change roles of team members * Delete the account ## Inviting members 1. Go to **Settings > Team** in the Dashboard. 2. Click **Invite Member** and enter their email address. 3. The invitee receives an email with a link to join your account. Invitees must verify their email address before accepting the invitation. If they don't have a Posthook account, they'll be prompted to create one. Pending invitations can be revoked from the Team settings page before they are accepted. ## Audit log All significant actions are recorded in the audit log, accessible from **Settings > Team > Audit Log**. Tracked actions include: * Team member additions and removals * Role changes * Project creation and deletion * Billing changes * API key and signing key rotations Each entry shows who performed the action, what changed, and when. ## Limits * Maximum **25 team members** per account. # Overview Source: https://docs.posthook.io/index Schedule webhooks for exactly when you need them. Reliable delivery with automatic retries. ## What is Posthook? Posthook is a simple API for scheduling webhooks. Set a time, provide a payload, and we deliver it to your endpoint. Full visibility into every hook from the dashboard. **Send a reminder email 24 hours before a meeting:** ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; const posthook = new Posthook('phk_your_api_key'); const hook = await posthook.hooks.schedule({ path: '/webhooks/meeting-reminder', postAt: '2026-03-14T12:00:00Z', data: { user_id: 'usr_123', meeting_id: 'mtg_456', message: 'Your meeting starts in 24 hours' } }); ``` ```python Python theme={null} from posthook import Posthook client = Posthook("phk_your_api_key") hook = client.hooks.schedule( "/webhooks/meeting-reminder", post_at="2026-03-14T12:00:00Z", data={ "user_id": "usr_123", "meeting_id": "mtg_456", "message": "Your meeting starts in 24 hours" } ) ``` ```go Go theme={null} client, _ := posthook.NewClient("phk_your_api_key") hook, _, _ := client.Hooks.Schedule(context.Background(), &posthook.HookScheduleParams{ Path: "/webhooks/meeting-reminder", PostAt: time.Date(2026, 3, 14, 12, 0, 0, 0, time.UTC), Data: map[string]string{ "user_id": "usr_123", "meeting_id": "mtg_456", "message": "Your meeting starts in 24 hours", }, }) ``` ```bash curl theme={null} curl -X POST https://api.posthook.io/v1/hooks \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "path": "/webhooks/meeting-reminder", "postAt": "2026-03-14T12:00:00Z", "data": { "user_id": "usr_123", "meeting_id": "mtg_456", "message": "Your meeting starts in 24 hours" } }' ``` ## Use Cases Posthook replaces cron jobs and internal task queues for time-based workflows: * **[Reminder emails](/patterns/user-engagement)** — Send onboarding drips, meeting reminders, or follow-ups at the right time. * **[Trial expirations](/patterns/auto-expiry)** — Schedule a hook when a trial starts to check status and downgrade at expiry. * **[Abandoned cart recovery](/patterns/user-engagement)** — Nudge users who left items in their cart after a delay. * **[Scheduled reports](/patterns/recurring-tasks)** — Generate and deliver reports on a recurring schedule. * **[Polling 3rd-party APIs](/patterns/polling)** — Turn any status-check API into an async webhook with recursive polling. ## When to Use Posthook Posthook is not a job queue. It's designed for scheduled, time-based webhooks — not high-volume fire-and-forget background jobs. If you need to process thousands of jobs per minute in real time, use a dedicated queue like SQS, BullMQ, or Celery. Posthook is built for use cases where *when* something happens matters: reminders, expirations, scheduled reports, and recurring workflows. ## Core Features Posthook is built around two primary use cases: ### 1. One-Time Callbacks Schedule HTTP requests to be delivered at a specific time in the future. Ideal for delayed job processing, notifications, and expiration checks. [**Start Scheduling**](/api-reference/endpoint/create-hook) ### 2. Recurring Workflows (Sequences) Chain multiple hooks together to create complex, time-based workflows. Sequences allow you to run steps in parallel or series, handle dependencies, and schedule recurring jobs. [**Learn about Sequences**](/essentials/sequences) ## Platform Features Automatic retries with configurable backoff ensure your hooks always arrive. Cryptographic signatures on every request so you can verify authenticity. Receive hooks on localhost with the CLI — no tunneling tools needed. Inspect payloads, track delivery status, and debug failures in real-time. Define sequences and settings in version-controlled TOML. ## Getting Started Ready to start scheduling? Send your first scheduled webhook in under 2 minutes. Understand the hook lifecycle from scheduling to delivery. # Auditing Source: https://docs.posthook.io/patterns/auditing Reconcile your internal state with delivery records You can use the List Hooks API to reconcile your internal state with Posthook's delivery records. This is useful for verifying that critical financial or compliance events were definitely sent. ## Workflow 1. **List Completed Hooks**: ```typescript TypeScript theme={null} for await (const hook of posthook.hooks.listAll({ status: 'completed', postAtAfter: '2025-01-01T00:00:00Z' })) { // Compare with your internal records console.log(hook.id, hook.path, hook.data); } ``` ```python Python theme={null} for hook in client.hooks.list_all( status="completed", post_at_after="2025-01-01T00:00:00Z" ): # Compare with your internal records print(hook.id, hook.path, hook.data) ``` ```go Go theme={null} for hook, err := range client.Hooks.ListAll(ctx, &posthook.HookListAllParams{ Status: "completed", PostAtAfter: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), }) { if err != nil { log.Fatal(err) } // Compare with your internal records fmt.Println(hook.ID, hook.Path, string(hook.Data)) } ``` ```bash curl theme={null} curl "https://api.posthook.io/v1/hooks?status=completed&postAtAfter=2025-01-01T00:00:00Z" \ -H "X-API-Key: $API_KEY" ``` 2. **Verify**: Iterate through the list and compare against your own transaction logs. 3. **Alert**: Flag any discrepancies (e.g., a transaction marked "processed" in your DB but missing a corresponding webhook). # Auto-Expiry (TTL) Source: https://docs.posthook.io/patterns/auto-expiry Automatically clean up temporary resources Temporary resources like shareable links, export files, or beta accounts need lifecycle management. Instead of running a nightly cleanup job, you can schedule the deletion at the exact moment of creation. ## Workflow 1. **Schedule**: When you create a resource, schedule its deletion for `X` hours later. 2. **Handle**: Delete the resource. ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; import { Signatures, SignatureVerificationError } from '@posthook/node'; const posthook = new Posthook(process.env.POSTHOOK_API_KEY); const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); /* 1. Schedule */ async function createShareableLink(fileId) { // 1. Create the temporary link const linkToken = await db.createLink({ fileId }); // 2. Schedule expiration for 24 hours await posthook.hooks.schedule({ path: '/webhooks/expire-link', postIn: '24h', data: { token: linkToken } }); return linkToken; } /* 2. Handle */ app.post('/webhooks/expire-link', async (req, res) => { try { const delivery = signatures.parseDelivery(req.body, req.headers); const { token } = delivery.data; // Check if it was already manually deleted? const link = await db.getLink(token); if (!link) { return res.status(200).send('Already deleted'); } // Perform the cleanup await db.deleteLink(token); res.status(200).send('Resource expired'); } catch (err) { if (err instanceof SignatureVerificationError) { return res.status(401).json({ error: err.message }); } throw err; } }); ``` ```python Python theme={null} from posthook import Posthook, SignaturesService, SignatureVerificationError from flask import Flask, request, jsonify import os client = Posthook(os.environ["POSTHOOK_API_KEY"]) app = Flask(__name__) signatures = SignaturesService(os.environ["POSTHOOK_SIGNING_KEY"]) # 1. Schedule def create_shareable_link(file_id): # 1. Create the temporary link link_token = db.create_link(file_id=file_id) # 2. Schedule expiration for 24 hours client.hooks.schedule( "/webhooks/expire-link", post_in="24h", data={"token": link_token} ) return link_token # 2. Handle @app.route("/webhooks/expire-link", methods=["POST"]) def handle_expire_link(): try: delivery = signatures.parse_delivery( request.get_data(), dict(request.headers) ) token = delivery.data["token"] # Check if it was already manually deleted? link = db.get_link(token) if not link: return "Already deleted", 200 # Perform the cleanup db.delete_link(token) return "Resource expired", 200 except SignatureVerificationError as err: return jsonify(error=err.message), 401 ``` ```go Go theme={null} package main import ( "context" "encoding/json" "fmt" "io" "net/http" "os" posthook "github.com/posthook/posthook-go" ) var client, _ = posthook.NewClient(os.Getenv("POSTHOOK_API_KEY")) var signatures, _ = posthook.NewSignatures(os.Getenv("POSTHOOK_SIGNING_KEY")) // 1. Schedule func createShareableLink(fileID string) string { linkToken := db.CreateLink(fileID) client.Hooks.Schedule(context.Background(), &posthook.HookScheduleParams{ Path: "/webhooks/expire-link", PostIn: "24h", Data: map[string]string{"token": linkToken}, }) return linkToken } // 2. Handle func handleExpireLink(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivery, err := signatures.ParseDelivery(body, r.Header) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var data struct { Token string `json:"token"` } json.Unmarshal(delivery.Data, &data) // Check if it was already manually deleted? link, _ := db.GetLink(data.Token) if link == nil { fmt.Fprint(w, "Already deleted") return } // Perform the cleanup db.DeleteLink(data.Token) fmt.Fprint(w, "Resource expired") } ``` # Dead Letter Queue Source: https://docs.posthook.io/patterns/dead-letter-queue Manage and replay failed webhooks The List Hooks endpoint can function as a Dead Letter Queue (DLQ). You can fetch all failed hooks within a specific time range, process them, and then delete them. ## Workflow 1. **List Failed Hooks**: Use `status=failed` and filters like `postAtAfter` (e.g., "yesterday"). ```typescript TypeScript theme={null} for await (const hook of posthook.hooks.listAll({ status: 'failed', postAtAfter: '2025-01-01T00:00:00Z' })) { console.log(hook.id, hook.path); } ``` ```python Python theme={null} for hook in client.hooks.list_all( status="failed", post_at_after="2025-01-01T00:00:00Z" ): print(hook.id, hook.path) ``` ```go Go theme={null} for hook, err := range client.Hooks.ListAll(ctx, &posthook.HookListAllParams{ Status: posthook.StatusFailed, PostAtAfter: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), }) { if err != nil { log.Fatal(err) } fmt.Println(hook.ID, hook.Path) } ``` ```bash curl theme={null} curl "https://api.posthook.io/v1/hooks?status=failed&postAtAfter=2025-01-01T00:00:00Z" \ -H "X-API-Key: $API_KEY" ``` 2. **Process**: Iterate through the list and handle the failures (e.g., log them, alert a team, or manually fix the data). 3. **Delete**: Once processed, delete the hook to remove it from the queue. ```bash theme={null} DELETE /hooks/{id} ``` **Automate this with Sequences**: You can set up a recurring [Sequence Pattern](/patterns/recurring-tasks) to trigger your DLQ processing endpoint every day. ## Bulk Retry If you had a temporary outage (e.g., your database was down), you can batch retry failures instead of deleting them. 1. **List Failed Hooks** (as above). 2. **Extract IDs**: Collect the `id` from each hook (or use the SDK's bulk retry directly). 3. **Bulk Retry**: ```typescript TypeScript theme={null} const result = await posthook.hooks.bulk.retry({ hookIDs: ['e5405623-2c1c-460e-9737-c884f7f59035', 'a1b2c3d4-5678-90ab-cdef-1234567890ab', 'f9e8d7c6-5432-10fe-dcba-0987654321fe'] }); console.log(`Retried ${result.affected} hooks`); ``` ```python Python theme={null} result = client.hooks.bulk.retry( hook_ids=["e5405623-2c1c-460e-9737-c884f7f59035", "a1b2c3d4-5678-90ab-cdef-1234567890ab", "f9e8d7c6-5432-10fe-dcba-0987654321fe"] ) print(f"Retried {result.affected} hooks") ``` ```go Go theme={null} result, _, err := client.Hooks.Bulk().Retry(ctx, &posthook.BulkActionByIDs{ HookIDs: []string{"e5405623-2c1c-460e-9737-c884f7f59035", "a1b2c3d4-5678-90ab-cdef-1234567890ab", "f9e8d7c6-5432-10fe-dcba-0987654321fe"}, }) fmt.Printf("Retried %d hooks\n", result.Affected) ``` ```bash curl theme={null} curl -X POST https://api.posthook.io/v1/hooks/bulk/retry \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "hookIDs": ["e5405623-2c1c-460e-9737-c884f7f59035", "a1b2c3d4-5678-90ab-cdef-1234567890ab", "f9e8d7c6-5432-10fe-dcba-0987654321fe"] }' ``` # Debouncing Source: https://docs.posthook.io/patterns/debouncing Prevent duplicate actions by verifying event freshness When events fire in rapid succession, such as a user saving a document repeatedly, you often only want to perform a reaction (like syncing to an external API) once the activity stops. Instead of cancelling previous hooks (which requires tracking IDs), you can use a **Stale Check** pattern. ## Workflow 1. **Schedule**: On every event, schedule a hook for `X` minutes in the future. Pass the current timestamp. 2. **Verify**: When the hook fires, compare the passed timestamp with the resource's current `updatedAt`. 3. **Discard**: If the resource is newer than the event, it means a subsequent event occurred. Discard this hook. ## Example: Sync to Salesforce Sync a user profile to Salesforce 5 minutes after their last edit. ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; import { Signatures, SignatureVerificationError } from '@posthook/node'; const posthook = new Posthook(process.env.POSTHOOK_API_KEY); const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); /* 1. Schedule */ async function onUserProfileUpdate(user) { // Always schedule the sync for 5 minutes later // Pass the creation time of THIS specific event await posthook.hooks.schedule({ path: '/webhooks/sync/salesforce', postIn: '5m', data: { userId: user.id, triggeredAt: new Date().toISOString() } }); } /* 2. Handle */ app.post('/webhooks/sync/salesforce', async (req, res) => { try { const delivery = signatures.parseDelivery(req.body, req.headers); const { userId, triggeredAt } = delivery.data; const user = await db.getUser(userId); // STALE CHECK: // If the user record has been updated SINCE this hook was scheduled, // then a newer hook exists in the queue. We can safely skip this one. if (new Date(user.updatedAt) > new Date(triggeredAt)) { return res.status(200).send('Stale request, skipping'); } // If timestamps match (or are very close), this is the latest action. await salesforce.sync(user); res.status(200).send('Synced'); } catch (err) { if (err instanceof SignatureVerificationError) { return res.status(401).json({ error: err.message }); } throw err; } }); ``` ```python Python theme={null} from posthook import Posthook, SignaturesService, SignatureVerificationError from flask import Flask, request, jsonify from datetime import datetime import os client = Posthook(os.environ["POSTHOOK_API_KEY"]) app = Flask(__name__) signatures = SignaturesService(os.environ["POSTHOOK_SIGNING_KEY"]) # 1. Schedule def on_user_profile_update(user): # Always schedule the sync for 5 minutes later # Pass the creation time of THIS specific event client.hooks.schedule( "/webhooks/sync/salesforce", post_in="5m", data={ "user_id": user["id"], "triggered_at": datetime.now().isoformat() } ) # 2. Handle @app.route("/webhooks/sync/salesforce", methods=["POST"]) def handle_sync_salesforce(): try: delivery = signatures.parse_delivery( request.get_data(), dict(request.headers) ) user_id = delivery.data["user_id"] triggered_at = delivery.data["triggered_at"] user = db.get_user(user_id) # STALE CHECK: # If the user record has been updated SINCE this hook was scheduled, # then a newer hook exists in the queue. We can safely skip this one. if datetime.fromisoformat(user["updated_at"]) > datetime.fromisoformat(triggered_at): return "Stale request, skipping", 200 # If timestamps match (or are very close), this is the latest action. salesforce.sync(user) return "Synced", 200 except SignatureVerificationError as err: return jsonify(error=err.message), 401 ``` ```go Go theme={null} package main import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "time" posthook "github.com/posthook/posthook-go" ) var client, _ = posthook.NewClient(os.Getenv("POSTHOOK_API_KEY")) var signatures, _ = posthook.NewSignatures(os.Getenv("POSTHOOK_SIGNING_KEY")) // 1. Schedule func onUserProfileUpdate(user User) { client.Hooks.Schedule(context.Background(), &posthook.HookScheduleParams{ Path: "/webhooks/sync/salesforce", PostIn: "5m", Data: map[string]string{ "userId": user.ID, "triggeredAt": time.Now().Format(time.RFC3339), }, }) } // 2. Handle func handleSyncSalesforce(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivery, err := signatures.ParseDelivery(body, r.Header) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var data struct { UserID string `json:"userId"` TriggeredAt string `json:"triggeredAt"` } json.Unmarshal(delivery.Data, &data) user, _ := db.GetUser(data.UserID) // STALE CHECK: // If the user record has been updated SINCE this hook was scheduled, // then a newer hook exists in the queue. We can safely skip this one. triggeredAt, _ := time.Parse(time.RFC3339, data.TriggeredAt) if user.UpdatedAt.After(triggeredAt) { fmt.Fprint(w, "Stale request, skipping") return } // If timestamps match (or are very close), this is the latest action. salesforce.Sync(user) fmt.Fprint(w, "Synced") } ``` This ensures that if a user updates their profile 10 times in one minute, only the **last** hook will actually trigger the sync. The previous 9 will detect they are "stale" and exit immediately. # Recursive Polling Source: https://docs.posthook.io/patterns/polling Turn any polling API into an async webhook If you use a third-party API that processes data (like video encoding or AI generation) but doesn't offer webhooks, you usually have to run a cron job to poll for status. With Posthook, you can create a "self-healing" loop that checks the status and reschedules itself if the job isn't done yet. ## Workflow 1. **Schedule**: Trigger the job and schedule the first check. 2. **Handle**: Check status. If `pending`, reschedule. If `completed`, process the result. ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; import { Signatures, SignatureVerificationError } from '@posthook/node'; const posthook = new Posthook(process.env.POSTHOOK_API_KEY); const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); /* 1. Schedule */ async function startVideoEncoding(videoFile) { // 1. Start the job on the 3rd party API const job = await transcoderApi.start(videoFile); // 2. Schedule the first check for 1 minute later await posthook.hooks.schedule({ path: '/webhooks/check-status', postIn: '1m', data: { jobId: job.id } }); } /* 2. Handle */ app.post('/webhooks/check-status', async (req, res) => { try { const delivery = signatures.parseDelivery(req.body, req.headers); const { jobId } = delivery.data; const job = await transcoderApi.get(jobId); if (job.status === 'completed') { // DONE: Process the result await db.saveVideo(job.url); return res.status(200).send('Video processed'); } if (job.status === 'failed') { // FAIL: Handle error (maybe alert team) return res.status(200).send('Job failed'); } // PENDING: Reschedule check for another minute await posthook.hooks.schedule({ path: '/webhooks/check-status', postIn: '1m', data: { jobId } }); res.status(200).send('Still pending, rescheduled'); } catch (err) { if (err instanceof SignatureVerificationError) { return res.status(401).json({ error: err.message }); } throw err; } }); ``` ```python Python theme={null} from posthook import Posthook, SignaturesService, SignatureVerificationError from flask import Flask, request, jsonify import os client = Posthook(os.environ["POSTHOOK_API_KEY"]) app = Flask(__name__) signatures = SignaturesService(os.environ["POSTHOOK_SIGNING_KEY"]) # 1. Schedule def start_video_encoding(video_file): # 1. Start the job on the 3rd party API job = transcoder_api.start(video_file) # 2. Schedule the first check for 1 minute later client.hooks.schedule( "/webhooks/check-status", post_in="1m", data={"job_id": job["id"]} ) # 2. Handle @app.route("/webhooks/check-status", methods=["POST"]) def handle_check_status(): try: delivery = signatures.parse_delivery( request.get_data(), dict(request.headers) ) job_id = delivery.data["job_id"] job = transcoder_api.get(job_id) if job["status"] == "completed": # DONE: Process the result db.save_video(job["url"]) return "Video processed", 200 if job["status"] == "failed": # FAIL: Handle error (maybe alert team) return "Job failed", 200 # PENDING: Reschedule check for another minute client.hooks.schedule( "/webhooks/check-status", post_in="1m", data={"job_id": job_id} ) return "Still pending, rescheduled", 200 except SignatureVerificationError as err: return jsonify(error=err.message), 401 ``` ```go Go theme={null} package main import ( "context" "encoding/json" "fmt" "io" "net/http" "os" posthook "github.com/posthook/posthook-go" ) var client, _ = posthook.NewClient(os.Getenv("POSTHOOK_API_KEY")) var signatures, _ = posthook.NewSignatures(os.Getenv("POSTHOOK_SIGNING_KEY")) // 1. Schedule func startVideoEncoding(videoFile string) { job := transcoderAPI.Start(videoFile) client.Hooks.Schedule(context.Background(), &posthook.HookScheduleParams{ Path: "/webhooks/check-status", PostIn: "1m", Data: map[string]string{"jobId": job.ID}, }) } // 2. Handle func handleCheckStatus(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivery, err := signatures.ParseDelivery(body, r.Header) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var data struct { JobID string `json:"jobId"` } json.Unmarshal(delivery.Data, &data) job := transcoderAPI.Get(data.JobID) if job.Status == "completed" { // DONE: Process the result db.SaveVideo(job.URL) fmt.Fprint(w, "Video processed") return } if job.Status == "failed" { // FAIL: Handle error (maybe alert team) fmt.Fprint(w, "Job failed") return } // PENDING: Reschedule check for another minute client.Hooks.Schedule(r.Context(), &posthook.HookScheduleParams{ Path: "/webhooks/check-status", PostIn: "1m", Data: map[string]string{"jobId": data.JobID}, }) fmt.Fprint(w, "Still pending, rescheduled") } ``` # Recurring Tasks Source: https://docs.posthook.io/patterns/recurring-tasks Schedule daily jobs and maintenance workflows Sequences are ideal for orchestrating recurring system-level tasks, such as daily reports, database maintenance, or health checks. ## Daily Metric Snapshots This pattern allows you to take a "snapshot" of your key performance indicators (KPIs) every day at midnight and report them to your team. ```yaml theme={null} # Daily KPI Snapshot snapshotRevenue: path: /webhooks/metrics/revenue snapshotActiveUsers: path: /webhooks/metrics/active-users notifySlack: path: /webhooks/notifications/slack requires: - snapshotRevenue - snapshotActiveUsers ``` ### Why this works 1. **Parallel Execution**: `snapshotRevenue` and `snapshotActiveUsers` capture data simultaneously. 2. **Aggregation**: `notifySlack` waits for both snapshots to finish successfully before posting a summary. 3. **Reliability**: Partial failures stop the reporting step, ensuring you only report complete data. ### Deploying this sequence Define the full sequence in `posthook.toml`: ```toml theme={null} [[sequences]] name = "daily-kpi-snapshot" start_at = "2026-03-01T00:00:00Z" [sequences.schedule] frequency = "daily" timezone = "America/New_York" time = { hour = 0, minute = 0 } [sequences.steps.snapshot-revenue] path = "/webhooks/metrics/revenue" [sequences.steps.snapshot-active-users] path = "/webhooks/metrics/active-users" [sequences.steps.notify-slack] path = "/webhooks/notifications/slack" depends_on = ["snapshot-revenue", "snapshot-active-users"] ``` ```bash theme={null} npx posthook apply ``` ## Periodic Health Checks You can run distributed health checks every few minutes to verify system uptime. ```yaml theme={null} # 5-min Health Check pingServiceA: path: /webhooks/service-a/health pingServiceB: path: /webhooks/service-b/health pingDatabase: path: /webhooks/database/health ``` All checks run in parallel, giving you a comprehensive snapshot of system health at that moment. # User Engagement Source: https://docs.posthook.io/patterns/user-engagement Schedule reminders, follow-ups, and recovery emails Posthook powers user engagement workflows that require timing delays, such as reminders or abandoned cart recovery. ## Event Reminders Send a reminder the evening before a scheduled event, at 5 PM in the user's local timezone. 1. **Schedule**: When the user books, schedule the reminder for the day before at 5 PM local. 2. **Handle**: When the hook fires, send the email. ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; import { Signatures, SignatureVerificationError } from '@posthook/node'; const posthook = new Posthook(process.env.POSTHOOK_API_KEY); const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); /* 1. Schedule */ async function onWebinarBooking(booking) { const reminder = new Date(booking.date); reminder.setUTCDate(reminder.getUTCDate() - 1); reminder.setUTCHours(17, 0, 0, 0); await posthook.hooks.schedule({ path: '/webhooks/send-email', postAtLocal: reminder.toISOString().split('.')[0], timezone: booking.timezone, // LEAST PRIVILEGE: Only send the ID. Don't send PII or template logic. data: { bookingId: booking.id, } }); } /* 2. Handle */ app.post('/webhooks/send-email', async (req, res) => { try { const delivery = signatures.parseDelivery(req.body, req.headers); const { bookingId } = delivery.data; const booking = await db.getBooking(bookingId); // VALIDITY CHECKS: if (!booking || booking.status === 'cancelled') { return res.status(200).send('Booking cancelled or not found'); } if (booking.reminderSent) { return res.status(200).send('Reminder already sent'); } // Derive logic from your DB, not the payload const template = booking.type === 'webinar' ? 'webinar_reminder' : 'event_reminder'; await emailService.send(booking.userId, template, { event: booking.event }); await db.markReminderSent(bookingId); res.status(200).send('Reminder sent'); } catch (err) { if (err instanceof SignatureVerificationError) { return res.status(401).json({ error: err.message }); } throw err; } }); ``` ```python Python theme={null} from posthook import Posthook, SignaturesService, SignatureVerificationError from flask import Flask, request, jsonify from datetime import datetime, timedelta import os client = Posthook(os.environ["POSTHOOK_API_KEY"]) app = Flask(__name__) signatures = SignaturesService(os.environ["POSTHOOK_SIGNING_KEY"]) # 1. Schedule def on_webinar_booking(booking): event_date = datetime.fromisoformat(booking["date"]) reminder = event_date.replace(hour=17, minute=0, second=0) - timedelta(days=1) client.hooks.schedule( "/webhooks/send-email", post_at_local=reminder.strftime("%Y-%m-%dT%H:%M:%S"), timezone=booking["timezone"], # LEAST PRIVILEGE: Only send the ID. Don't send PII or template logic. data={"booking_id": booking["id"]} ) # 2. Handle @app.route("/webhooks/send-email", methods=["POST"]) def handle_send_email(): try: delivery = signatures.parse_delivery( request.get_data(), dict(request.headers) ) booking_id = delivery.data["booking_id"] booking = db.get_booking(booking_id) # VALIDITY CHECKS: if not booking or booking["status"] == "cancelled": return "Booking cancelled or not found", 200 if booking["reminder_sent"]: return "Reminder already sent", 200 # Derive logic from your DB, not the payload template = "webinar_reminder" if booking["type"] == "webinar" else "event_reminder" email_service.send(booking["user_id"], template, {"event": booking["event"]}) db.mark_reminder_sent(booking_id) return "Reminder sent", 200 except SignatureVerificationError as err: return jsonify(error=err.message), 401 ``` ```go Go theme={null} package main import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "time" posthook "github.com/posthook/posthook-go" ) var client, _ = posthook.NewClient(os.Getenv("POSTHOOK_API_KEY")) var signatures, _ = posthook.NewSignatures(os.Getenv("POSTHOOK_SIGNING_KEY")) // 1. Schedule func onWebinarBooking(booking Booking) { eventTime, _ := time.Parse(time.RFC3339, booking.Date) d := eventTime.AddDate(0, 0, -1) reminder := time.Date(d.Year(), d.Month(), d.Day(), 17, 0, 0, 0, time.UTC) client.Hooks.Schedule(context.Background(), &posthook.HookScheduleParams{ Path: "/webhooks/send-email", PostAtLocal: reminder.Format("2006-01-02T15:04:05"), Timezone: booking.Timezone, // LEAST PRIVILEGE: Only send the ID. Don't send PII or template logic. Data: map[string]string{"bookingId": booking.ID}, }) } // 2. Handle func handleSendEmail(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivery, err := signatures.ParseDelivery(body, r.Header) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var data struct { BookingID string `json:"bookingId"` } json.Unmarshal(delivery.Data, &data) booking, err := db.GetBooking(data.BookingID) // VALIDITY CHECKS: if err != nil || booking == nil || booking.Status == "cancelled" { fmt.Fprint(w, "Booking cancelled or not found") return } if booking.ReminderSent { fmt.Fprint(w, "Reminder already sent") return } // Derive logic from your DB, not the payload template := "event_reminder" if booking.Type == "webinar" { template = "webinar_reminder" } emailService.Send(booking.UserID, template, map[string]string{"event": booking.Event}) db.MarkReminderSent(data.BookingID) fmt.Fprint(w, "Reminder sent") } ``` The `reminderSent` check prevents most duplicates, but there's a small race window if a retry arrives before the flag is committed. If you need stricter guarantees, see [Idempotency](/essentials/receiving-webhooks#idempotency). ## Abandoned Cart Recovery If a user adds items to their cart but doesn't checkout, schedule a "nudge" email for 30 minutes later. Instead of trying to cancel the hook when a user purchases, it's often simpler to **verify validity at execution time**. 1. **Schedule**: When the user adds an item, schedule the email for 30 minutes later. 2. **Verify**: When the webhook handler receives the request, check if the cart has been converted to an order. ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; import { Signatures, SignatureVerificationError } from '@posthook/node'; const posthook = new Posthook(process.env.POSTHOOK_API_KEY); const signatures = new Signatures(process.env.POSTHOOK_SIGNING_KEY); /* 1. Schedule the Nudge */ async function onAddToCart(cart) { await posthook.hooks.schedule({ path: '/webhooks/cart/abandoned', postIn: '30m', data: { cartId: cart.id } }); } /* 2. Handle & Verify */ app.post('/webhooks/cart/abandoned', async (req, res) => { try { const delivery = signatures.parseDelivery(req.body, req.headers); const { cartId } = delivery.data; const cart = await db.getCarts(cartId); // IDEMPOTENCY CHECK: // If the cart is already "completed" or "paid", do nothing. if (cart.status === 'completed') { return res.status(200).send('Cart already recovered'); } // Otherwise, send the email await sendRecoveryEmail(cart.userId); res.status(200).send('Email sent'); } catch (err) { if (err instanceof SignatureVerificationError) { return res.status(401).json({ error: err.message }); } throw err; } }); ``` ```python Python theme={null} from posthook import Posthook, SignaturesService, SignatureVerificationError from flask import Flask, request, jsonify import os client = Posthook(os.environ["POSTHOOK_API_KEY"]) app = Flask(__name__) signatures = SignaturesService(os.environ["POSTHOOK_SIGNING_KEY"]) # 1. Schedule the Nudge def on_add_to_cart(cart): client.hooks.schedule( "/webhooks/cart/abandoned", post_in="30m", data={"cart_id": cart["id"]} ) # 2. Handle & Verify @app.route("/webhooks/cart/abandoned", methods=["POST"]) def handle_abandoned_cart(): try: delivery = signatures.parse_delivery( request.get_data(), dict(request.headers) ) cart_id = delivery.data["cart_id"] cart = db.get_cart(cart_id) # IDEMPOTENCY CHECK: # If the cart is already "completed" or "paid", do nothing. if cart["status"] == "completed": return "Cart already recovered", 200 # Otherwise, send the email send_recovery_email(cart["user_id"]) return "Email sent", 200 except SignatureVerificationError as err: return jsonify(error=err.message), 401 ``` ```go Go theme={null} package main import ( "context" "encoding/json" "fmt" "io" "net/http" "os" posthook "github.com/posthook/posthook-go" ) var client, _ = posthook.NewClient(os.Getenv("POSTHOOK_API_KEY")) var signatures, _ = posthook.NewSignatures(os.Getenv("POSTHOOK_SIGNING_KEY")) // 1. Schedule the Nudge func onAddToCart(cart Cart) { client.Hooks.Schedule(context.Background(), &posthook.HookScheduleParams{ Path: "/webhooks/cart/abandoned", PostIn: "30m", Data: map[string]string{"cartId": cart.ID}, }) } // 2. Handle & Verify func handleAbandonedCart(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivery, err := signatures.ParseDelivery(body, r.Header) if err != nil { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var data struct { CartID string `json:"cartId"` } json.Unmarshal(delivery.Data, &data) cart, _ := db.GetCart(data.CartID) // IDEMPOTENCY CHECK: // If the cart is already "completed" or "paid", do nothing. if cart.Status == "completed" { fmt.Fprint(w, "Cart already recovered") return } // Otherwise, send the email sendRecoveryEmail(cart.UserID) fmt.Fprint(w, "Email sent") } ``` # Quickstart Source: https://docs.posthook.io/quickstart Start scheduling webhooks in under 2 minutes This guide walks you through setting up Posthook, connecting your local environment, and sending your first scheduled hook. Prefer an interactive guide? You can follow this same walkthrough interactively in the [Posthook Dashboard](https://posthook.io/app/home). ## 1. Log In Connect your local development server to Posthook using the CLI. This lets you receive webhooks locally without tunneling tools like ngrok. ```bash npm theme={null} npx posthook login ``` ```bash yarn theme={null} yarn dlx posthook login ``` ```bash pnpm theme={null} pnpm dlx posthook login ``` You will be prompted to enter your **API Secret Key**. You can find this in the **Settings** page of your project in the [Dashboard](https://posthook.io/app/home). ## 2. Start the Listener Start the Posthook listener to forward webhooks to your local application. ```bash theme={null} npx posthook listen --forward http://localhost:3000 ``` Replace `http://localhost:3000` with the base URL of your local server. The listener connects to Posthook via WebSocket. It receives webhooks and forwards them to your specified local URL. ## 3. Install the SDK (Optional) Install an official SDK for a streamlined experience. You can also use `curl` directly. ```bash TypeScript theme={null} npm install @posthook/node ``` ```bash Python theme={null} pip install posthook-python ``` ```bash Go theme={null} go get github.com/posthook/posthook-go ``` ## 4. Send a Test Hook In a new terminal, schedule a test webhook to fire in 5 seconds. ```typescript TypeScript theme={null} import Posthook from '@posthook/node'; const posthook = new Posthook('phk_your_api_key'); const hook = await posthook.hooks.schedule({ path: '/webhooks/posthook/send-reminder', postIn: '5s', data: { user_id: 'usr_123', reminder_id: 'rem_456' } }); console.log('Scheduled:', hook.id); ``` ```python Python theme={null} from posthook import Posthook client = Posthook("phk_your_api_key") hook = client.hooks.schedule( path="/webhooks/posthook/send-reminder", post_in="5s", data={ "user_id": "usr_123", "reminder_id": "rem_456" } ) print("Scheduled:", hook.id) ``` ```go Go theme={null} client, _ := posthook.NewClient("phk_your_api_key") hook, _, _ := client.Hooks.Schedule(context.Background(), &posthook.HookScheduleParams{ Path: "/webhooks/posthook/send-reminder", PostIn: "5s", Data: map[string]string{ "user_id": "usr_123", "reminder_id": "rem_456", }, }) fmt.Println("Scheduled:", hook.ID) ``` ```bash curl theme={null} curl -X POST https://api.posthook.io/v1/hooks \ -H "Content-Type: application/json" \ -H "X-API-Key: $POSTHOOK_API_KEY" \ -d '{ "path": "/webhooks/posthook/send-reminder", "postIn": "5s", "data": { "user_id": "usr_123", "reminder_id": "rem_456" } }' ``` You can find your API Key in the **Settings** page of your project in the [Dashboard](https://posthook.io/app/home). ### Verification Check your `posthook listen` terminal. You should see the hook arrive when it fires: ``` 2026-01-01 12:00:00 [200 OK] POST /webhooks/posthook/send-reminder ``` ## 5. Check the Dashboard Open the [Activity page](https://posthook.io/app/home) in the Dashboard. You can inspect the full payload, view delivery timing, and check response details for any hook. ## Next Steps Understand the full hook lifecycle from scheduling to delivery. Handle deliveries in your app with framework-specific examples. Verify the `Posthook-Signature` to ensure requests are genuine. Explore the full API to schedule, list, and manage your hooks. # CLI Source: https://docs.posthook.io/reference/cli Command reference for the Posthook CLI ## Installation No global installation required. Run the CLI on-demand with `npx`. ```bash theme={null} npx posthook ``` ## Commands | Command | Description | | ---------- | ------------------------------------------------------------------------------------------ | | `login` | Store your Posthook API key | | `logout` | Clear stored API keys | | `listen` | [Receive webhooks on localhost](/essentials/local-development#listen) via WebSocket | | `init` | [Pull remote config](/essentials/config-as-code#init) into a local `posthook.toml` | | `pull` | [Download remote config](/essentials/config-as-code#pull), overwriting the local file | | `apply` | [Push local config](/essentials/config-as-code#apply) to the remote project | | `diff` | [Show pending changes](/essentials/config-as-code#diff) between local and remote | | `status` | [Display project info](/essentials/config-as-code#status), signing key, and masked API key | | `validate` | [Check TOML syntax](/essentials/config-as-code#validate) locally without an API call | ## Common flags | Flag | Applies to | Description | | --------------------- | ----------------------------------------------------- | ------------------------------------------------ | | `-k, --api-key ` | `listen`, `init`, `pull`, `apply`, `diff` | Override the stored API key | | `-c, --config ` | `init`, `pull`, `apply`, `diff`, `status`, `validate` | Use a named config file (`posthook..toml`) | You can also set the `POSTHOOK_API_KEY` environment variable instead of using `--api-key`. # API Errors Source: https://docs.posthook.io/reference/errors Error codes, response format, and how to handle them When an API request fails, Posthook returns a JSON error response with an HTTP status code and a descriptive message. ## Error Response Format ```json theme={null} { "error": "path is required" } ``` | Field | Description | | ------- | --------------------------------------------------- | | `error` | A human-readable string describing what went wrong. | ## Error Codes ### 400 Bad Request The request body is malformed or fails validation. ```json theme={null} { "error": "path is required" } ``` **Common causes:** * Missing required fields (`path`, `data`) * Invalid `postAt` format (must be RFC 3339) * Providing both `postAt` and `postIn` (mutually exclusive) * `postAtLocal` without `timezone` * Payload exceeds 64 KB **Fix:** Check the [API Reference](/api-reference/endpoint/create-hook) for required fields and valid values. ### 401 Unauthorized The API key is missing or invalid. ```json theme={null} { "error": "Invalid API key" } ``` **Common causes:** * Missing `X-API-Key` header * Invalid or revoked API key * Using a signing key instead of an API key **Fix:** Verify your API key in **Project Settings** in the [Dashboard](https://posthook.io/app/home). ### 404 Not Found The requested resource does not exist. ```json theme={null} { "error": "Hook not found" } ``` **Common causes:** * Hook ID does not exist or belongs to a different project * Endpoint path is incorrect ### 413 Payload Too Large The request body exceeds the maximum allowed size. ```json theme={null} { "error": "Payload exceeds 64 KB limit" } ``` **Fix:** Reduce the size of your `data` field. If you need to send large payloads, store the data externally and include a reference URL in the hook payload. ### 429 Too Many Requests You've exceeded your plan's monthly hook quota. ```json theme={null} { "error": "Rate limit exceeded" } ``` **Fix:** Check your usage with the `Posthook-HookQuota-*` [response headers](/essentials/scheduling#quota-headers), or upgrade your plan. ### 500 Internal Server Error An unexpected error occurred on Posthook's end. ```json theme={null} { "error": "An unexpected error occurred" } ``` **Fix:** Retry the request with backoff. If the issue persists, contact [support@posthook.io](mailto:support@posthook.io). ## SDK Error Handling ```typescript TypeScript theme={null} import Posthook, { PosthookError } from '@posthook/node'; const posthook = new Posthook('phk_your_api_key'); try { const hook = await posthook.hooks.schedule({ path: '/webhooks/remind', postAt: '2026-03-14T12:00:00Z', data: { user_id: '123' } }); } catch (err) { if (err instanceof PosthookError) { console.error(err.status, err.code, err.message); } } ``` ```python Python theme={null} from posthook import Posthook, PosthookError client = Posthook("phk_your_api_key") try: hook = client.hooks.schedule( "/webhooks/remind", post_at="2026-03-14T12:00:00Z", data={"user_id": "123"} ) except PosthookError as err: print(err.status, err.code, err.message) ``` ```go Go theme={null} hook, resp, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{ Path: "/webhooks/remind", PostAt: time.Date(2026, 3, 14, 12, 0, 0, 0, time.UTC), Data: map[string]string{"user_id": "123"}, }) if err != nil { var apiErr *posthook.Error if errors.As(err, &apiErr) { fmt.Println(apiErr.StatusCode, apiErr.Code, apiErr.Message) } } ``` # Troubleshooting Source: https://docs.posthook.io/reference/troubleshooting Common issues and how to fix them ## My hook didn't fire * **Check hook status in the Dashboard** — Is it still `pending`? If so, the scheduled time hasn't arrived yet. * **Verify `postAt` is in the past** — Hooks only fire once their scheduled time is reached. Use `postIn: "5s"` for quick tests. * **Check if your plan quota is exhausted** — Look at the `Posthook-HookQuota-Remaining` header in your `POST /hooks` responses. See [Usage quota headers](/essentials/scheduling#quota-headers). * **Confirm the project domain is set** — Hooks are delivered to `https://{domain}{path}`. If your domain isn't configured, there's nowhere to deliver. Check [Project Settings](/essentials/project-settings). ## My hook keeps retrying * **Your endpoint is returning non-2xx** — Check your server logs. Posthook treats any non-2xx response as a failure. * **Your endpoint is timing out** — Posthook enforces a 10-second delivery timeout. If your handler takes longer, offload to a background queue and return `200 OK` immediately. See [Handling Long-Running Tasks](/essentials/receiving-webhooks#handling-long-running-tasks). * **Check retry config** — Review your project's retry settings in [Project Settings](/essentials/project-settings). Individual hooks may also have [per-hook retry overrides](/essentials/scheduling#per-hook-retry-override). ## Signature verification is failing * **Verify you're using the correct key** — Use the **Signing Key**, not the API key. Find it in Project Settings in the [Dashboard](https://posthook.io/app/home). * **Check for body parsing middleware** — Middleware that parses or modifies the raw request body before verification will break the signature. Verify against the raw body bytes. * **If you recently rotated keys** — During the grace period, `Posthook-Signature` includes signatures for both keys. Make sure your code checks both. See [Key Rotation](/essentials/security#key-rotation). * **Clock skew** — `parseDelivery()` rejects requests older than 5 minutes by default. Ensure your server's clock is synchronized (NTP). ## Getting 429 Too Many Requests * **You've exceeded your monthly hook quota** — Check `Posthook-HookQuota-Remaining` headers to confirm. See [Usage quota headers](/essentials/scheduling#quota-headers). Upgrade your plan or wait for the quota to reset. ## Hooks are delivered to the wrong URL * **Check your project domain** — The delivery URL is `https://{domain}{path}`. Verify the domain in [Project Settings](/essentials/project-settings). * **HTTPS is enforced** — All deliveries use HTTPS. Make sure your endpoint supports it. * **Check for trailing slashes or typos** — The path in the hook request is used exactly as-is. Double-check for extra slashes or typos. ## CLI can't connect * **Check your API key** — Run `npx posthook login` to re-enter your key. You can find it in Project Settings. * **Firewall or proxy** — The CLI connects via WebSocket. Make sure outbound WebSocket connections aren't blocked by your network. * **Try a fresh session** — Run `npx posthook logout` then `npx posthook login` to reset your stored credentials.