Skip to main content
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) 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.
{
  "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

HeaderDescription
Content-Typeapplication/json
Posthook-IdUnique hook ID (e.g. e5405623-2c1c-460e-9737-c884f7f59035)
Posthook-TimestampUnix timestamp (seconds) of when the hook was posted
Posthook-SignatureOne or more v1,<hex> signatures, space-separated
X-Ph-SignatureLegacy HMAC-SHA256 signature (hex-encoded). See Legacy Header.
User-AgentPosthook/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.
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;
  }
});
For a deep dive on how signatures work, key rotation, and replay protection, see the 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 to configure defaults for your project.

Per-hook retry override

Individual hooks can override your project’s retry settings via the retryOverride field.
{
  "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 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.
{
  "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.
// 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');
});
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 and Stripe’s Designing with 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. To handle this, implement an Async Pattern:
  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 pattern.