Schedule Webhooks from Go
Schedule delayed tasks and webhooks from Go with net/http. Durable scheduling with retries and delivery tracking — no goroutine timers, no polling, no infrastructure to manage.
Last updated: March 24, 2026
Go’s standard library has no built-in scheduling beyond time.AfterFunc, which does not survive restarts. Common alternatives — gocron, database polling with goroutines — require managing state, concurrency, and persistence yourself.
Posthook provides durable scheduling without the operational weight. Schedule a hook with the Go SDK, and receive the delivery at a standard net/http handler when the time arrives. The SDK is idiomatic Go: typed errors with errors.As(), context.Context throughout, and net/http compatible.
Install
go get github.com/posthook/posthook-go
Create the client:
import posthook "github.com/posthook/posthook-go"
client, err := posthook.NewClient("phk_...",
posthook.WithSigningKey("ph_sk_..."),
)
if err != nil {
log.Fatal(err)
}
Pass an empty string to read the API key from the POSTHOOK_API_KEY environment variable instead.
Schedule a hook
When an order is placed, schedule a payment timeout check for 30 minutes later:
hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{
Path: "/webhooks/order-timeout",
PostIn: "30m",
Data: map[string]any{"orderId": order.ID},
})
if err != nil {
log.Printf("Failed to schedule hook: %v", err)
return
}
The hook is persisted by Posthook. It survives deploys, restarts, and scaling events.
For an absolute UTC time, use PostAt with a time.Time:
hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{
Path: "/webhooks/trial-expiry",
PostAt: time.Now().Add(14 * 24 * time.Hour),
Data: map[string]any{"userId": user.ID},
})
For DST-aware local scheduling, use PostAtLocal with a Timezone:
hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{
Path: "/webhooks/daily-digest",
PostAtLocal: "2026-03-25T09:00:00",
Timezone: "America/New_York",
Data: map[string]any{"userId": user.ID},
})
Receive and verify the delivery
The handler reads the raw body, verifies the signature, checks state, and decides whether to act:
func handleOrderTimeout(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
delivery, err := client.Signatures.ParseDelivery(body, r.Header)
if err != nil {
var sigErr *posthook.SignatureVerificationError
if errors.As(err, &sigErr) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var data struct {
OrderID string `json:"orderId"`
}
json.Unmarshal(delivery.Data, &data)
order, err := db.GetOrder(r.Context(), data.OrderID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if order.Status != "pending" {
// Already paid, cancelled, or expired — safe to skip
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "skipped"})
return
}
db.ExpireOrder(r.Context(), order.ID)
notifyCustomer(order, "payment_timeout")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "expired"})
}
The state check makes this handler idempotent. Posthook guarantees at-least-once delivery — the handler may receive the same delivery more than once. Since it checks order.Status before acting, duplicates resolve as no-ops.
Async hooks
Go’s goroutine model is a natural fit for async hooks. Return 202 immediately, process in a goroutine, and call back when done:
func handleVideoTranscode(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)
}()
}
Ack() marks the hook as delivered. Nack() triggers a retry according to your project’s retry settings. Both return a *CallbackResult with Applied (whether the state changed) and Status fields.
Retry override for critical hooks
Payment timeouts are more critical than marketing reminders. Use RetryOverride to get more aggressive retries without changing project defaults:
hook, _, err := client.Hooks.Schedule(ctx, &posthook.HookScheduleParams{
Path: "/webhooks/order-timeout",
PostIn: "30m",
Data: map[string]any{"orderId": order.ID},
RetryOverride: &posthook.HookRetryOverride{
MinRetries: 5,
DelaySecs: 10,
Strategy: "exponential",
BackoffFactor: 2.0,
MaxDelaySecs: 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.
Local development
Use the Posthook CLI to forward hook deliveries to your local Go server — no deploy, no public URL:
npx posthook listen --forward http://localhost:8080
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
- Expiration and timeout checks — the complete pattern with DIY comparison
- Reminders and follow-ups — onboarding, trials, follow-up chains
- Cron vs durable scheduling — when cron works and when it breaks
- 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.