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

Frequently asked questions

Ready to get started?

Create your free account and start scheduling hooks in minutes. No credit card required.