Expiration API: Schedule Timeouts and Deadlines

Last updated: April 23, 2026

You need something to happen when a deadline passes. A pending invitation expires in 7 days. A held reservation releases after 30 minutes. A payment hold times out at 3pm. A promo code stops working at midnight. Each of these is a per-object deadline. It has to fire at a specific time, not “sometime in the next cron cycle,” and it has to keep firing through deploys, restarts, and downstream outages.

Posthook is a webhook scheduler. You schedule a hook when the object is created (invitation sent, order placed, trial started). At the deadline, Posthook fires an HTTP POST to your endpoint. Your handler checks the object’s current state and decides: expire it, or skip if something changed. You keep your database and your business logic. Posthook handles the timing and the operational layer around it.

Common expiration use cases

  • Pending payment or order timeout: release held inventory if payment isn’t completed within a window. Time-sensitive. A 30-second drift matters.
  • Invitation expiry: mark invitations expired after 7 days if not accepted. Allow the inviter to extend the deadline.
  • Trial window enforcement: downgrade access when the trial period ends. Often scheduled in the user’s local time for a clean end-of-day experience.
  • Offer and promo code expiration: invalidate a discount at a specific moment, globally or per user.
  • Reservation and hold release: free up a booking slot if the user doesn’t confirm within the hold period.
  • Session, magic link, and auth token expiration: invalidate short-lived tokens at the security deadline (session expiry, password reset links, OAuth state, magic links). Complements auth platforms like Auth0, Clerk, and NextAuth. They manage the token. Posthook triggers the invalidation.
  • Feature flags and scheduled rollouts: expire a feature flag or scheduled rollout at a specific moment (beta program end, gradual rollout cutoff, kill switch flip).
  • Account deletion grace period: schedule the actual data purge after a cooling-off window, with the option to cancel if the user changes their mind.

Each of these is one scheduled action per object, per event, or per record. Not a recurring cron cadence. The deadline is dynamic, set by something that just happened in your application.

How Posthook handles expiration scheduling

Seven steps:

  1. A triggering event creates an object with a deadline (order placed, invitation sent, trial started).
  2. Your application schedules a hook via the Posthook API with the deadline and a payload identifier.
  3. Posthook persists the schedule and handles the timing.
  4. At the deadline, Posthook delivers an HTTP POST or WebSocket message to your endpoint.
  5. Your handler reads the identifier, looks up current state, and decides: expire the object, or skip if state changed (already paid, invitation accepted, trial extended).
  6. If your endpoint fails, Posthook retries with configurable backoff.
  7. If delivery outcomes deviate from your endpoint’s baseline, anomaly detection alerts you. A single failure on a normally-healthy endpoint is enough.
flowchart LR
A[Object created<br/>order, invitation, trial]
B[Posthook<br/>persists schedule]
C[Handler<br/>reads identifier<br/>looks up state]
D[Expire object]
E[Skip]
A -->|schedule hook<br/>at deadline| B
B -->|fires at deadline| C
C -->|still pending| D
C -->|already resolved| E

classDef default fill:#111113,stroke:#27272a,stroke-width:1px,color:#fafafa,rx:6,ry:6
classDef accent fill:#111113,stroke:#7CFEF0,stroke-width:1.5px,color:#fafafa,rx:6,ry:6
classDef outcome fill:#0a0a0b,stroke:#3f3f46,stroke-width:1px,color:#a1a1aa,rx:6,ry:6
class C accent
class D,E outcome
Expiration lifecycle. The triggering event creates a scheduled hook at the deadline. At fire time, the handler reads the identifier, checks state, and either expires the object or skips.

Schedule an order timeout 30 minutes after the order is placed:

import Posthook from '@posthook/node';

const posthook = new Posthook('phk_...', { signingKey: 'phsk_...' });

const hook = await posthook.hooks.schedule({
  path: '/webhooks/order-timeout',
  postIn: '30m',
  data: { orderId: order.id },
});

await db.order.update(order.id, { timeoutHookId: hook.id });

When the hook fires, the handler verifies the signature, parses the delivery, and decides:

app.post('/webhooks/order-timeout', async (req, res) => {
  const delivery = posthook.signatures.parseDelivery(req.body, req.headers);
  const order = await db.getOrder(delivery.data.orderId);

  if (!order || order.paid || order.cancelled) {
    return res.status(200).json({ status: 'skipped' });
  }

  await releaseInventoryHold(order);
  await db.order.update(order.id, { status: 'expired' });
  return res.status(200).json({ status: 'expired' });
});

The handler uses posthook.signatures.parseDelivery from the SDK to verify the signature and parse the payload (configure express.raw({ type: '*/*' }) so the raw body is available). Then it checks state. If the order was paid before the deadline, the delivery resolves as a no-op. Cancellation with posthook.hooks.delete(hookId) is a cleanup optimization to avoid unnecessary deliveries. The handler stays safe to run regardless.

Expiration scenarios with Posthook

Four common expiration shapes. The handler pattern stays the same across all of them: parse the delivery, look up state, expire or skip.

Trial expiration with downgrade

Schedule the downgrade at the end of the trial window in the user’s local time. If the user subscribes before the deadline, the handler skips. If they extend the trial, schedule a new hook.

Posthook complements your billing platform (Stripe, Chargebee, Lemon Squeezy). Your billing system manages subscription state. Posthook triggers the downgrade at the deadline.

// When the trial starts (14-day window, ending at end of the local day)
const trialEndsAt = addDays(new Date(), 14);

const hook = await posthook.hooks.schedule({
  path: '/webhooks/trial-end',
  postAtLocal: formatLocal(trialEndsAt, '23:59:59'),
  timezone: user.timezone,
  data: { userId: user.id },
});

await db.user.update(user.id, {
  trialEndsAt,
  trialEndHookId: hook.id,
});
app.post('/webhooks/trial-end', async (req, res) => {
  const delivery = posthook.signatures.parseDelivery(req.body, req.headers);
  const user = await db.getUser(delivery.data.userId);

  if (!user || user.subscribed) {
    return res.status(200).json({ status: 'skipped' });
  }

  // Check if the trial was extended past this deadline
  if (user.trialEndsAt && user.trialEndsAt > new Date()) {
    return res.status(200).json({ status: 'skipped-extended' });
  }

  await downgradeToFree(user);
  return res.status(200).json({ status: 'downgraded' });
});

When the trial is extended:

async function extendTrial(user, additionalDays) {
  const newEnd = addDays(user.trialEndsAt, additionalDays);

  const hook = await posthook.hooks.schedule({
    path: '/webhooks/trial-end',
    postAtLocal: formatLocal(newEnd, '23:59:59'),
    timezone: user.timezone,
    data: { userId: user.id },
  });

  await posthook.hooks.delete(user.trialEndHookId).catch(() => {});
  await db.user.update(user.id, {
    trialEndsAt: newEnd,
    trialEndHookId: hook.id,
  });
}

If cancellation misses, the original hook still fires. The trialEndsAt > now() check in the handler makes it skip.

Invitation expiry

Schedule expiry 7 days after the invitation is sent. If the invitee accepts before the deadline, the handler skips. If the inviter extends the deadline, the handler reads the updated expiresAt and skips if it has moved forward.

// When the invitation is sent
const hook = await posthook.hooks.schedule({
  path: '/webhooks/invitation-expire',
  postIn: '7d',
  data: { invitationId: invitation.id },
});

await db.invitation.update(invitation.id, {
  expiresAt: addDays(new Date(), 7),
  expireHookId: hook.id,
});
app.post('/webhooks/invitation-expire', async (req, res) => {
  const delivery = posthook.signatures.parseDelivery(req.body, req.headers);
  const invitation = await db.getInvitation(delivery.data.invitationId);

  if (!invitation || invitation.status !== 'pending') {
    return res.status(200).json({ status: 'skipped' });
  }

  // Skip if the invitation's deadline was extended past now
  if (invitation.expiresAt > new Date()) {
    return res.status(200).json({ status: 'skipped-extended' });
  }

  await db.invitation.update(invitation.id, { status: 'expired' });
  await notifyInviter(invitation);
  return res.status(200).json({ status: 'expired' });
});

Extending the invitation becomes a one-row update plus a new hook. No migration of pending rows, no cron reconfiguration.

Offer and promo code expiration

Invalidate a promo code at a specific moment. For global offers (same deadline for everyone), schedule one hook. For personalized offers (different deadline per user), schedule one hook per user.

// Global offer expiry: Black Friday code ends at midnight UTC
await posthook.hooks.schedule({
  path: '/webhooks/offer-expire',
  postAt: '2026-11-28T00:00:00Z',
  data: { offerId: 'black-friday-2026' },
});
app.post('/webhooks/offer-expire', async (req, res) => {
  const delivery = posthook.signatures.parseDelivery(req.body, req.headers);
  const offer = await db.getOffer(delivery.data.offerId);

  if (!offer || offer.status === 'expired') {
    return res.status(200).json({ status: 'skipped' });
  }

  await db.offer.update(offer.id, { status: 'expired' });
  await invalidateCachedCoupons(offer.id);
  return res.status(200).json({ status: 'expired' });
});

For a personalized offer with a per-user window:

// When the user claims a 72-hour discount
await posthook.hooks.schedule({
  path: '/webhooks/personal-offer-expire',
  postIn: '72h',
  data: { userId: user.id, offerCode: code },
});

Pending order and payment hold timeout

Release the hold if payment isn’t completed within the window. Time-sensitive. Set a more aggressive retry strategy so a brief endpoint hiccup doesn’t leak held inventory.

Posthook complements your payment processor (Stripe Payment Intents, Adyen, Braintree). Your processor handles the authorization and capture. Posthook triggers the inventory release if the user doesn’t complete checkout in time.

// When the order is placed with a 30-minute payment hold
await posthook.hooks.schedule({
  path: '/webhooks/order-timeout',
  postIn: '30m',
  data: { orderId: order.id },
  retryOverride: {
    minRetries: 5,
    delaySecs: 10,
    strategy: 'exponential',
    backoffFactor: 2.0,
    maxDelaySecs: 300,
  },
});
app.post('/webhooks/order-timeout', async (req, res) => {
  const delivery = posthook.signatures.parseDelivery(req.body, req.headers);
  const order = await db.getOrder(delivery.data.orderId);

  if (!order || order.paid || order.cancelled) {
    return res.status(200).json({ status: 'skipped' });
  }

  await releaseInventoryHold(order);
  await db.order.update(order.id, { status: 'expired' });
  return res.status(200).json({ status: 'expired' });
});

retryOverride tunes retries per-hook without changing project defaults. For order timeouts you generally want more attempts with shorter delays than the defaults used for background reminders.

What you get beyond the scheduled call

Expiration logic is usually low-frequency (most objects resolve before the deadline) and high-stakes when it fires. A missed trial downgrade means a paid-plan user on free. A missed payment hold means leaked inventory. Posthook adds the operational layer that matters when expirations are part of your production system:

  • Per-endpoint anomaly detection. Posthook tracks each endpoint’s success rate against a rolling baseline. A single failure on a normally-healthy endpoint is enough to trigger an alert by email, Slack, or webhook. The cause might be a deploy that broke the database read, the payments API rate-limiting, or the endpoint URL changing. No threshold to set, no monitoring stack to maintain.
  • Bulk replay after a downstream outage. If your database was unreachable for ten minutes and 50 expirations failed during that window, bulk retry replays them by endpoint and time range in one API call.
  • Per-hook delivery history. Every scheduled expiration has an attempt history: when it fired, which response came back, how long the endpoint took, which retry attempt succeeded. When a customer asks why their trial wasn’t downgraded on time, the hook’s delivery record has the answer.
  • Per-hook retry overrides. Time-sensitive expirations (payment holds, reservation releases, magic link invalidations) can take a more aggressive retry strategy without changing project defaults. Set retryOverride at scheduling time.
  • Cancellation for cleanup. When the user completes payment or extends their trial, cancel the pending hook with posthook.hooks.delete(hookId) to avoid the unnecessary delivery. The handler’s state check is the correctness mechanism. Cancellation is an optimization.

Compared to building it yourself

CapabilityDIY (cron + expirations table)Posthook
Per-object schedulingInsert a row with expires_at, poll for due rows on a cron cadenceOne API call. Fires at the deadline.
Time-sensitive precisionBounded by the cron interval (up to 59s late at a 1-minute cadence)Second-level precision at the scheduled time
Retry on failureBuild retry logic, track attempts, handle backoffRetries with backoff, jitter, and per-hook overrides
Coordination across instancesAdvisory locks, SKIP LOCKED, or Kubernetes concurrencyPolicyOne scheduled hook per object. No scan, no locking.
Delivery visibilityQuery your own logs or build a dashboardPer-hook status with attempt history
Failure alertingBuild monitoring around the cron jobPer-endpoint anomaly detection with alerts via email, Slack, or webhook
Bulk recovery after an incidentWrite a SQL script per incidentBulk retry by endpoint and time range
Timezone and DSTConvert to UTC, handle DST in codepostAtLocal + timezone, DST handled at scheduling time
Extension or reschedulingUpdate expires_at, supersede or delete the old rowSchedule a new hook. The handler’s state check covers both old and new.
Object stateYou manage itYou manage it
InfrastructureCron, database table, polling query, locking, retry logic, monitoringAPI key, endpoint

Why an expirations table tends to grow into a subsystem

A cron job polling an expirations table is the usual starting point. It works longer than most teams expect, which is why the maintenance burden often arrives late: scanning overhead as the pending set grows, cron-interval drift on time-sensitive deadlines, coordination locks to avoid duplicate processing, and the pile of monitoring you realize you need the first time an expiration silently stops firing. By the fourth object type with its own expiration rules, you’re maintaining a small subsystem you didn’t set out to build. The expiration and timeout checks pattern covers the design decisions that make a reliable per-object expiration system work.

When not to use Posthook for expirations

Nightly batch cleanup. If the job is “archive all orders older than 90 days,” a cron query by date range is simpler. Posthook fits when each object has its own deadline that’s set dynamically at creation time.

Complex conditional expiration workflows. If the decision at expiration time branches across many conditions (“if on grace, extend; if upgraded, cancel; if paused, apply different limits”), the branching is your handler’s job. Posthook schedules when to fire the check, not the branching logic. For multi-step orchestration, see Inngest or Trigger.dev or Temporal.

Immediate expiration. If the action needs to fire the moment the object’s state changes (not at a future deadline), that’s event-driven, not scheduled. A queue or direct function call fits better.

Pricing for expiration workloads

Posthook charges per scheduled hook. Retries, deliveries, and API calls don’t count toward the quota.

  • Free: 1,000 hooks/month. Suits early-stage testing and small workloads.
  • Launch: $39/month for 20,000 hooks. May fit teams scheduling up to roughly 20K expirations/month.
  • Growth: $99/month for 100,000 hooks.
  • Scale: $249/month for 500,000 hooks.

A signup flow with one trial-end hook is one hook per signup. An e-commerce flow with one payment-hold hook per order is one hook per order. Only the scheduled hook counts toward quota. If the handler skips because state changed, that’s a free delivery. See pricing for the full breakdown.

Getting started

Install the SDK:

npm install @posthook/node

Schedule an expiration relative to now:

import Posthook from '@posthook/node';

const posthook = new Posthook('phk_...');

await posthook.hooks.schedule({
  path: '/webhooks/invitation-expire',
  postIn: '7d',
  data: { invitationId: 'inv_456' },
});

Schedule an expiration at an exact UTC deadline:

await posthook.hooks.schedule({
  path: '/webhooks/offer-expire',
  postAt: '2026-11-28T00:00:00Z',
  data: { offerId: 'black-friday-2026' },
});

Schedule in the user’s local time, with DST handled at scheduling time:

await posthook.hooks.schedule({
  path: '/webhooks/trial-end',
  postAtLocal: '2026-05-15T23:59:59',
  timezone: 'America/New_York',
  data: { userId: 'user_123' },
});

Point the path at a handler on your service. When the hook fires, your handler checks current state and decides whether to expire or skip.

For per-hook retry overrides, cancellation, bulk replay, and the full API, see the Posthook docs.

Frequently asked questions

Ready to get started?

Free plan includes 1,000 hooks/month. No credit card required.