The Webhook Delivery Workflow guarantees at-least-once delivery of event notifications to merchant-configured HTTPS endpoints. It implements exponential backoff with a fixed retry schedule, HMAC-SHA256 payload signing, and dead-letter queue (DLQ) escalation on exhaustion.
package workflows
import (
"time"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// WebhookPayload represents the event to be delivered.
type WebhookPayload struct {
EventID string // Unique event identifier (CloudEvents id)
EventType string // e.g. payin.success, payout.failed
MerchantID string
Timestamp time.Time
Data map[string]interface{} // Event-specific payload
}
// MerchantWebhookConfig holds the merchant's webhook settings.
type MerchantWebhookConfig struct {
WebhookID string
MerchantID string
URL string // HTTPS endpoint
Secret string // HMAC-SHA256 signing secret
Version string // Payload version (e.g. 2024-01-01)
EventTypes []string // Subscribed event types
}
// WebhookDeliveryResult is the final outcome.
type WebhookDeliveryResult struct {
EventID string
Delivered bool
Attempts int
FinalStatus string // DELIVERED, DLQ, CANCELLED
LastError string
CompletedAt time.Time
}
// retrySchedule defines the fixed backoff intervals for webhook delivery.
// Total span: ~91 hours (approximately 3.8 days).
var retrySchedule = []time.Duration{
1 * time.Minute,
5 * time.Minute,
30 * time.Minute,
2 * time.Hour,
12 * time.Hour,
24 * time.Hour,
72 * time.Hour,
}
// WebhookDeliveryWorkflow delivers a webhook payload to a merchant endpoint.
//
// Workflow ID: webhook-{eventId} (ensures idempotency per event)
// Task Queue: webhook-worker
func WebhookDeliveryWorkflow(ctx workflow.Context, payload WebhookPayload, config MerchantWebhookConfig) (*WebhookDeliveryResult, error) {
logger := workflow.GetLogger(ctx)
// -----------------------------------------------------------------
// Activity options
// -----------------------------------------------------------------
serialiseOpts := workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 3,
},
}
signOpts := workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 3,
},
}
deliverOpts := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 1, // We manage retries ourselves via the schedule
},
}
dlqOpts := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 5,
BackoffCoefficient: 2.0,
},
}
// -----------------------------------------------------------------
// Query handler: delivery status
// -----------------------------------------------------------------
deliveryStatus := &DeliveryStatus{
EventID: payload.EventID,
Status: "PENDING",
Attempts: 0,
}
err := workflow.SetQueryHandler(ctx, "delivery-status", func() (*DeliveryStatus, error) {
return deliveryStatus, nil
})
if err != nil {
logger.Error("Failed to register query handler", "error", err)
}
// -----------------------------------------------------------------
// Step 1: Serialise Payload
// -----------------------------------------------------------------
serialiseCtx := workflow.WithActivityOptions(ctx, serialiseOpts)
var serialisedPayload []byte
err = workflow.ExecuteActivity(serialiseCtx, SerialisePayload, SerialiseInput{
Payload: payload,
Version: config.Version,
}).Get(ctx, &serialisedPayload)
if err != nil {
return &WebhookDeliveryResult{
EventID: payload.EventID,
Delivered: false,
FinalStatus: "SERIALISATION_FAILED",
LastError: err.Error(),
CompletedAt: workflow.Now(ctx),
}, nil
}
// -----------------------------------------------------------------
// Step 2: Sign Payload (HMAC-SHA256)
// -----------------------------------------------------------------
signCtx := workflow.WithActivityOptions(ctx, signOpts)
var signature string
err = workflow.ExecuteActivity(signCtx, SignPayload, SignPayloadInput{
Payload: serialisedPayload,
Secret: config.Secret,
}).Get(ctx, &signature)
if err != nil {
return &WebhookDeliveryResult{
EventID: payload.EventID,
Delivered: false,
FinalStatus: "SIGNING_FAILED",
LastError: err.Error(),
CompletedAt: workflow.Now(ctx),
}, nil
}
// -----------------------------------------------------------------
// Step 3: Attempt Delivery with Retry Schedule
// -----------------------------------------------------------------
deliverCtx := workflow.WithActivityOptions(ctx, deliverOpts)
maxAttempts := 1 + len(retrySchedule) // initial attempt + retries
var lastError string
for attempt := 0; attempt < maxAttempts; attempt++ {
deliveryStatus.Attempts = attempt + 1
deliveryStatus.Status = "DELIVERING"
var response DeliveryResponse
err = workflow.ExecuteActivity(deliverCtx, DeliverWebhook, DeliverWebhookInput{
URL: config.URL,
Payload: serialisedPayload,
Signature: signature,
EventID: payload.EventID,
EventType: payload.EventType,
Attempt: attempt + 1,
}).Get(ctx, &response)
if err == nil {
// ---------------------------------------------------------
// Step 4: Verify Response
// ---------------------------------------------------------
verifyCtx := workflow.WithActivityOptions(ctx, serialiseOpts)
var verified bool
_ = workflow.ExecuteActivity(verifyCtx, VerifyResponse, VerifyResponseInput{
StatusCode: response.StatusCode,
Body: response.Body,
}).Get(ctx, &verified)
if verified {
deliveryStatus.Status = "DELIVERED"
return &WebhookDeliveryResult{
EventID: payload.EventID,
Delivered: true,
Attempts: attempt + 1,
FinalStatus: "DELIVERED",
CompletedAt: workflow.Now(ctx),
}, nil
}
}
lastError = ""
if err != nil {
lastError = err.Error()
}
deliveryStatus.LastError = lastError
// Wait before next retry (unless this was the last attempt)
if attempt < len(retrySchedule) {
deliveryStatus.Status = "WAITING_RETRY"
_ = workflow.Sleep(ctx, retrySchedule[attempt])
}
}
// -----------------------------------------------------------------
// Step 5: Send to Dead Letter Queue (all retries exhausted)
// -----------------------------------------------------------------
deliveryStatus.Status = "DLQ"
dlqCtx := workflow.WithActivityOptions(ctx, dlqOpts)
_ = workflow.ExecuteActivity(dlqCtx, SendToDLQ, DLQInput{
EventID: payload.EventID,
EventType: payload.EventType,
MerchantID: payload.MerchantID,
Payload: serialisedPayload,
Attempts: maxAttempts,
LastError: lastError,
URL: config.URL,
}).Get(ctx, nil)
return &WebhookDeliveryResult{
EventID: payload.EventID,
Delivered: false,
Attempts: maxAttempts,
FinalStatus: "DLQ",
LastError: lastError,
CompletedAt: workflow.Now(ctx),
}, nil
}