Schedule Webhooks from Python
Schedule delayed tasks and webhooks from Python without Celery or Redis. One API call to schedule, HTTP delivery with signature verification. Works with FastAPI, Flask, and Django.
Last updated: March 24, 2026
Celery with Redis or RabbitMQ is the standard approach for delayed work in Python, but it adds real infrastructure for what is often just a timing problem. In-process alternatives like APScheduler and schedule do not survive deploys or restarts.
Posthook handles scheduling and delivery externally. Schedule a hook from any Python code, and receive the delivery at an HTTP endpoint in your web framework when the time arrives. No broker, no workers, no polling loop.
Install
pip install posthook-python
Create the client:
import posthook
client = posthook.Posthook("phk_...", signing_key="ph_sk_...")
Both values can also be set via POSTHOOK_API_KEY and POSTHOOK_SIGNING_KEY environment variables.
Schedule a hook
When a user signs up, schedule an onboarding reminder for 48 hours later:
hook = client.hooks.schedule(
path="/webhooks/onboarding-reminder",
post_in="48h",
data={"user_id": user.id},
)
The hook is persisted by Posthook. It survives deploys, restarts, and scaling events. Three scheduling modes are available:
# Relative delay
hook = client.hooks.schedule(
path="/webhooks/onboarding-reminder",
post_in="48h",
data={"user_id": user.id},
)
# Absolute UTC time
hook = client.hooks.schedule(
path="/webhooks/trial-expiration",
post_at="2026-06-15T10:00:00Z",
data={"user_id": user.id},
)
# Local time with timezone (DST-safe)
hook = client.hooks.schedule(
path="/webhooks/daily-digest",
post_at_local="2026-03-15T09:00:00",
timezone=user.timezone,
data={"user_id": user.id},
)
Receive the delivery — FastAPI
The handler reads the raw body for signature verification, checks state, and decides whether to act:
from fastapi import FastAPI, Request, Response
import posthook
app = FastAPI()
client = posthook.Posthook("phk_...", signing_key="ph_sk_...")
@app.post("/webhooks/onboarding-reminder")
async def handle_webhook(request: Request):
body = await request.body()
try:
delivery = client.signatures.parse_delivery(
body=body,
headers=dict(request.headers),
)
except posthook.SignatureVerificationError:
return Response(status_code=401)
user = await db.get_user(delivery.data["user_id"])
if user.onboarding_completed:
# Already completed onboarding — safe to skip
return Response(status_code=200)
await send_onboarding_email(user)
return Response(status_code=200)
await request.body() returns raw bytes before any JSON parsing — this is what parse_delivery needs for signature verification. The state check makes this handler idempotent. Posthook guarantees at-least-once delivery, so the handler may run more than once. Since it checks user.onboarding_completed before acting, duplicates resolve as no-ops.
Other frameworks
The scheduling code is identical regardless of framework. Only the raw body access differs in the handler.
Flask
from flask import Flask, request
import posthook
app = Flask(__name__)
client = posthook.Posthook("phk_...", signing_key="ph_sk_...")
@app.route("/webhooks/onboarding-reminder", methods=["POST"])
def handle_webhook():
try:
delivery = client.signatures.parse_delivery(
body=request.get_data(),
headers=dict(request.headers),
)
except posthook.SignatureVerificationError:
return "invalid signature", 401
user = db.get_user(delivery.data["user_id"])
if user.onboarding_completed:
return "", 200
send_onboarding_email(user)
return "", 200
Django
from django.http import HttpResponse
import posthook
client = posthook.Posthook("phk_...", signing_key="ph_sk_...")
def handle_webhook(request):
try:
delivery = client.signatures.parse_delivery(
body=request.body,
headers=dict(request.headers),
)
except posthook.SignatureVerificationError:
return HttpResponse(status=401)
user = User.objects.get(id=delivery.data["user_id"])
if user.onboarding_completed:
return HttpResponse(status=200)
send_onboarding_email(user)
return HttpResponse(status=200)
Retry override for critical hooks
Payment timeouts and expiration checks are more critical than marketing reminders. Use retry_override to get more aggressive retries without changing project defaults:
hook = client.hooks.schedule(
path="/webhooks/payment-timeout",
post_in="30m",
data={"order_id": order.id},
retry_override=posthook.HookRetryOverride(
min_retries=5,
delay_secs=10,
strategy="exponential",
backoff_factor=2.0,
max_delay_secs=300,
),
)
This hook retries up to 5 times with exponential backoff — 10s, 20s, 40s, 80s, 160s — while other hooks in the project use the default strategy. Per-hook retry overrides are set at scheduling time.
Async hooks with FastAPI
When async hooks are enabled, your handler can return 202 immediately and process work in the background. The delivery includes ack_url and nack_url for reporting the outcome when done — configurable timeouts up to 3 hours.
from fastapi import FastAPI, Request, BackgroundTasks
from fastapi.responses import Response
import posthook
app = FastAPI()
client = posthook.Posthook("phk_...", signing_key="ph_sk_...")
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()
try:
delivery = client.signatures.parse_delivery(body=body, headers=dict(request.headers))
except posthook.SignatureVerificationError:
return Response(status_code=401)
background_tasks.add_task(process_and_ack, delivery)
return Response(status_code=202)
FastAPI’s BackgroundTasks runs the processing after the 202 response is sent. If neither ack nor nack is called before the timeout, the hook is retried.
Local development
Use the Posthook CLI to forward hook deliveries to your local server — no deploy, no public URL:
npx posthook listen --forward http://localhost:8000
The CLI connects via WebSocket and forwards each delivery as an HTTP POST to your local server with the same headers and payload as production. Your handler’s response determines the outcome — 200 means delivered, 202 starts an async hook flow, and anything else triggers a retry. Retries are handled by Posthook the same way they are in production, with the same backoff and attempt limits. Schedule hooks normally from your app — they arrive at your local endpoint.
Next steps
- Quickstart guide — full setup walkthrough
- Reminders and follow-ups — onboarding, trials, follow-up chains
- Expiration and timeout checks — the complete pattern with DIY comparison
- Celery vs Posthook — when to use a task queue and when to use managed scheduling
- Pricing — free plan includes 1,000 hooks/month
Frequently asked questions
Ready to get started?
Create your free account and start scheduling hooks in minutes. No credit card required.