How to Build Reliable Expirations and Timeouts
Last updated: April 23, 2026
Expirations look simple: set a deadline, fire when it passes. The complexity is in the gap. By the deadline, the object may have already resolved, the deadline may have moved, or the action may need to fire faster than your default retry policy.
Scheduling decides when to check. The handler decides whether to expire.
Four design principles for reliable expirations
1. Schedule the expiration when the object is created
The default instinct is to insert a row with expires_at and have a cron job scan for due rows. That works at low volume, but it makes scheduling a property of the polling cadence and turns precision into a tradeoff.
A scheduled job per object replaces the polling subsystem. Each fires at its own deadline. No scan, no coordination across instances, no precision floor at the cron interval.
-- Naive: scan for due rows on a cron cadence
SELECT * FROM orders WHERE expires_at <= NOW() AND status = 'pending';
// Per-object scheduling: schedule at the moment of creation
await scheduler.schedule({
postIn: '30m',
path: '/webhooks/order-timeout',
data: { orderId: order.id },
});
The payload carries only the identifier. State lives in your application. The scheduler doesn’t know whether the order is paid, cancelled, or still pending.
2. The deadline triggers a check, not an action
By the deadline, the object may have already resolved itself. The user paid, the invitation got accepted, the trial got extended. The job firing means it’s time to look. The decision to expire belongs to the handler, not the scheduler.
Read the identifier from the payload, look up current state, then decide: expire or skip.
app.post('/webhooks/order-timeout', async (req, res) => {
const order = await db.getOrder(req.body.data.orderId);
if (!order || order.paid || order.cancelled) {
return res.status(200).json({ status: 'skipped' });
}
await releaseHold(order);
return res.status(200).json({ status: 'expired' });
});
Skipping is the happy path more often than not. Most pending orders get paid. Most invitations get accepted. Most trials convert. An expiration that skips because the object resolved before the deadline is the system behaving correctly.
Because reliable schedulers generally deliver at-least-once, the handler should also be safe to run twice. The state check covers it: if the action already happened, current state reflects that, and the second run skips.
3. Rescheduling is a new scheduled job, not a coordination problem
When the deadline changes (the trial is extended, the invitation is renewed, the order grace period is bumped), schedule a new job for the updated deadline. Optionally cancel the old one. Don’t try to mutate or “reschedule” an existing job in coordination with the application.
async function extend(object, additionalDays) {
const newDeadline = addDays(object.expiresAt, additionalDays);
const newHook = await scheduler.schedule({
postAt: newDeadline.toISOString(),
path: '/webhooks/object-expire',
data: { objectId: object.id },
});
await scheduler.delete(object.expireHookId).catch(() => {});
await db.object.update(object.id, {
expiresAt: newDeadline,
expireHookId: newHook.id,
});
}
If cancellation misses, the original job still fires. The handler’s state check (read the object’s current expiresAt) makes it skip. Cancellation is a cleanup optimization, not a correctness mechanism.
4. Time-sensitivity drives precision and retry policy
Not all expirations are the same shape. A nightly trial-end check has different operational requirements than a 30-minute payment hold. Time-sensitive expirations want shorter retries, more attempts, and faster alerts. Batch-grade expirations are fine with default backoff.
Use per-job retry overrides for the cases that matter. For payment holds, magic link invalidations, and reservation releases, a delayed expiration leaks something (held inventory, a still-valid auth token, a locked slot). Set a more aggressive retry strategy at scheduling time.
await scheduler.schedule({
postIn: '30m',
path: '/webhooks/payment-hold-timeout',
data: { orderId: order.id },
retryOverride: {
minRetries: 5,
delaySecs: 10,
strategy: 'exponential',
backoffFactor: 2.0,
maxDelaySecs: 300,
},
});
The default retry policy is for the typical case. Override per-job when the cost of a delayed expiration is high.
flowchart LR A[Object created<br/>with deadline] B[Scheduled job<br/>carries identifier] C[Handler reads identifier<br/>looks up current state] D[Expire object] E[Skip] A -->|schedule| B B -->|fires at deadline| C C -->|still pending| D C -->|state changed| 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
What’s left to build
The four principles define the shape. The rest is machinery:
- Scheduling each expiration at creation time
- Retries and backoff when delivery fails
- Coordination so duplicate handler runs don’t cause double-actions
- Delivery visibility and debugging
- Per-job retry tuning for time-sensitive cases
That’s where the implementation starts to feel less like one expiration and more like a timing subsystem.
Why this becomes a subsystem
A cron job polling an expirations table is the usual starting point. It works longer than people expect, which is exactly why the maintenance burden often arrives late.
A few things tend to accumulate:
- Per-object precision becomes a polling tradeoff. Poll frequently and do more work; poll less and accept drift. A one-minute cadence means up to 59 seconds late.
- Failures need a path. Delivery failures need attempt tracking, retry policy, and somewhere to go when retries are exhausted. You build it or you lose expirations silently.
- Coordination locks become required. Multiple instances scanning the same table need advisory locks or
SKIP LOCKEDto avoid duplicate processing of the same row. - Visibility doesn’t appear by accident. “Why didn’t this expire?” and “Which expirations failed yesterday?” become product and support questions, not just infrastructure ones.
- Each new object type compounds the work. Trial windows, payment holds, reservation releases, magic link expiry. By the fourth concern, you’re maintaining four polling jobs with four slightly different failure modes.
Put together, this is the timing subsystem you didn’t set out to build.
Building this with Posthook
Posthook is a webhook scheduler that maps cleanly to the four design principles above. You schedule an expiration when the object is created, Posthook delivers the webhook at the deadline, and your handler still owns the state check: expire or skip.
Three expirations that show how the principles play out: a relative-deadline expiry, a deadline that can change mid-life, and a time-sensitive timeout where retry policy matters.
Handlers use posthook.signatures.parseDelivery from the SDK to verify the signature and parse the payload. Configure express.raw({ type: '*/*' }) so the raw body is available.
Invitation expiry
Schedule the expiry when the invitation is sent. When the hook fires, check whether the invitation is still pending.
import Posthook from '@posthook/node';
const posthook = new Posthook('phk_...', { signingKey: 'phsk_...' });
await posthook.hooks.schedule({
path: '/webhooks/invitation-expire',
postIn: '7d',
data: { invitationId: invitation.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' });
}
await db.invitation.update(invitation.id, { status: 'expired' });
return res.status(200).json({ status: 'expired' });
});
Trial expiration with extension
Schedule the trial-end hook at signup. When the trial is extended, schedule a new hook and let the handler’s state check cover the old one.
// At signup
const trialEndsAt = addDays(new Date(), 14);
const hook = await posthook.hooks.schedule({
path: '/webhooks/trial-end',
postAt: trialEndsAt.toISOString(),
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' });
}
if (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, schedule a new hook for the updated deadline:
async function extendTrial(user, additionalDays) {
const newEnd = addDays(user.trialEndsAt, additionalDays);
const hook = await posthook.hooks.schedule({
path: '/webhooks/trial-end',
postAt: newEnd.toISOString(),
data: { userId: user.id },
});
await posthook.hooks.delete(user.trialEndHookId).catch(() => {});
await db.user.update(user.id, {
trialEndsAt: newEnd,
trialEndHookId: hook.id,
});
}
Payment hold timeout
Release the inventory hold if payment isn’t completed within 30 minutes. The aggressive retry strategy matters because a delayed expiration leaks held stock.
await posthook.hooks.schedule({
path: '/webhooks/payment-hold-timeout',
postIn: '30m',
data: { orderId: order.id },
retryOverride: {
minRetries: 5,
delaySecs: 10,
strategy: 'exponential',
backoffFactor: 2.0,
maxDelaySecs: 300,
},
});
app.post('/webhooks/payment-hold-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 still owns the decision. Read current state, then expire or skip. Cancellation with posthook.hooks.delete(hookId) is still cleanup, not correctness.
What Posthook adds is the operational shape that fits expirations specifically. Per-hook retry overrides for the time-sensitive cases (payment holds, magic links, reservation slots). Per-endpoint anomaly detection that catches the silent-absence failure mode where an expiration just stops firing. Bulk replay for when an outage takes out a batch of expirations together and you need to recover them by endpoint and time range.
Compared to building it yourself
The four design principles don’t change. The work around them does.
| Capability | DIY (cron + expirations table) | Posthook |
|---|---|---|
| Per-object scheduling | Insert a row with expires_at, poll for due rows on a cron cadence | One API call. Fires at the deadline. |
| Time-sensitive precision | Bounded by the cron interval (up to 59s late at a 1-minute cadence) | Second-level precision at the scheduled time |
| Retry on failure | Build retry logic, track attempts, handle backoff | Retries with backoff, jitter, and per-hook overrides |
| Coordination across instances | Advisory locks, SKIP LOCKED, or Kubernetes concurrencyPolicy | One scheduled hook per object. No scan, no locking. |
| Delivery visibility | Query your own logs or build a dashboard | Per-hook status with attempt history |
| Failure alerting | Build monitoring around the cron job | Per-endpoint anomaly detection with alerts via email, Slack, or webhook |
| Bulk recovery after an incident | Write a SQL script per incident | Bulk retry by endpoint and time range |
| Timezone and DST | Convert to UTC, handle DST in code | postAtLocal + timezone, DST handled at scheduling time |
| Object state | You manage it | You manage it |
| Infrastructure | Cron, database table, polling query, locking, retry logic, monitoring | API key, endpoint |
When not to use Posthook
Nightly batch cleanup. “Archive all orders older than 90 days” is a cron query by date range. Posthook fits when each object has its own deadline that’s set dynamically at creation.
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.
Getting started
Install the SDK:
npm install @posthook/node
Most expiration flows need one of three scheduling modes:
// Relative: fire 30 minutes from now
await posthook.hooks.schedule({
path: '/webhooks/order-timeout',
postIn: '30m',
data: { orderId: 'order_123' },
});
// Exact UTC: fire at a specific moment
await posthook.hooks.schedule({
path: '/webhooks/offer-expire',
postAt: '2026-11-28T00:00:00Z',
data: { offerId: 'black-friday-2026' },
});
// Local time with timezone: fire at the end of the user's day
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 own service. Posthook calls it when the hook fires, with the payload you supplied. The handler owns the decision from there.
For the full API, including per-hook retry overrides, cancellation, and replay, see the Posthook docs.
Frequently asked questions
Ready to get started?
Free plan includes 1,000 hooks/month. No credit card required.