Use Cases
Webhook Use Cases
This page walks through complete webhook integrations for three common providers. Each example shows the full flow: webhook definition with signature verification, an event-handling function, and serve wiring.
The Pattern
Every webhook integration follows the same three steps:
1. Define webhook → verify signature + transform payload into an Ironflow event2. Write function → handle the event with durable steps3. Wire it up → pass both to serve() or register via CLIThe webhook definition is the only part that changes between providers. The rest of the machinery — deduplication, retry, step memoization — is handled by Ironflow.
Stripe: Payment Processing
Stripe sends webhook events for payment lifecycle changes. The most common use case is fulfilling orders when a payment succeeds.
What Stripe sends: JSON payload with a type field (e.g., payment_intent.succeeded), a top-level id for deduplication, and a data.object containing the resource. Stripe signs requests with an HMAC-SHA256 signature in the Stripe-Signature header using a v1=<hex> format.
1. Define the webhook:
import { createWebhook } from "@ironflow/node";import crypto from "crypto";
const stripeWebhook = createWebhook({ id: "stripe", verify: (req) => { const header = req.headers["stripe-signature"]; if (!header) throw new Error("Missing Stripe-Signature header");
// Stripe header format: t=<timestamp>,v1=<hex>,v0=<hex> const parts: Record<string, string> = {}; for (const segment of header.split(",")) { const [k, v] = segment.split("="); if (k && v) parts[k] = v; } if (!parts.t || !parts.v1) throw new Error("Malformed Stripe signature");
const signed = `${parts.t}.${req.body}`; const expected = crypto .createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET!) .update(signed) .digest("hex");
const a = Buffer.from(parts.v1, "hex"); const b = Buffer.from(expected, "hex"); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { throw new Error("Invalid signature"); } }, transform: (payload: any) => ({ name: `stripe.${payload.type}`, data: payload.data.object, idempotencyKey: payload.id, }),});2. Write the function:
import { createFunction } from "@ironflow/node";
const fulfillOrder = createFunction( { id: "fulfill-order", triggers: [{ event: "stripe.payment_intent.succeeded" }], }, async ({ event, step }) => { const payment = event.data as { id: string; amount: number; currency: string; metadata: { orderId: string }; };
await step.run("update-order-status", async () => { // Mark the order as paid in your database return { orderId: payment.metadata.orderId, status: "paid" }; });
await step.run("send-confirmation", async () => { // Send a confirmation email to the customer return { sent: true }; });
return { fulfilled: true, orderId: payment.metadata.orderId }; },);3. Wire it up:
import { serve } from "@ironflow/node";
export default serve({ functions: [fulfillOrder], webhooks: [stripeWebhook], serverUrl: process.env.IRONFLOW_URL,});1. Define the webhook:
package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "strings"
"github.com/sahina/ironflow/sdk/go/ironflow")
var stripeWebhook = ironflow.CreateWebhook(ironflow.WebhookConfig{ ID: "stripe", Verify: func(req *ironflow.WebhookRequest) error { sig := req.Header.Get("Stripe-Signature") if sig == "" { return fmt.Errorf("missing Stripe-Signature header") } mac := hmac.New(sha256.New, []byte(os.Getenv("STRIPE_WEBHOOK_SECRET"))) mac.Write(req.Body) expected := hex.EncodeToString(mac.Sum(nil)) if !strings.Contains(sig, "v1="+expected) { return fmt.Errorf("invalid signature") } return nil }, Transform: func(payload []byte) (*ironflow.WebhookEvent, error) { var event struct { ID string `json:"id"` Type string `json:"type"` Data struct { Object json.RawMessage `json:"object"` } `json:"data"` } if err := json.Unmarshal(payload, &event); err != nil { return nil, err } return &ironflow.WebhookEvent{ Name: "stripe." + event.Type, Data: event.Data.Object, IdempotencyKey: event.ID, }, nil },})2. Write the function:
var fulfillOrder = ironflow.CreateFunction(ironflow.FunctionConfig{ ID: "fulfill-order", Triggers: []ironflow.Trigger{{Event: "stripe.payment_intent.succeeded"}},}, func(ctx ironflow.Context) (any, error) { var payment struct { ID string `json:"id"` Amount int `json:"amount"` Metadata struct { OrderID string `json:"orderId"` } `json:"metadata"` } ctx.Event.Data(&payment)
status, err := ironflow.Run(ctx, "update-order-status", func() (any, error) { return map[string]any{"orderId": payment.Metadata.OrderID, "status": "paid"}, nil }) if err != nil { return nil, err }
_, err = ironflow.Run(ctx, "send-confirmation", func() (any, error) { return map[string]any{"sent": true}, nil }) if err != nil { return nil, err }
return map[string]any{"fulfilled": true, "orderStatus": status}, nil})3. Wire it up:
handler := ironflow.Serve(ironflow.ServeConfig{ Functions: []ironflow.Function{fulfillOrder}, Webhooks: []ironflow.Webhook{stripeWebhook}, ServerURL: os.Getenv("IRONFLOW_URL"),})
http.Handle("/api/ironflow", handler)http.ListenAndServe(":3000", nil)Stripe tip
Stripe retries failed webhook deliveries for up to 3 days. Because Ironflow deduplicates by the idempotencyKey you return from transform() (here, payload.id), retries are handled automatically — the same event won’t be processed twice.
Production verification
The hand-rolled verifier above is shown for clarity. For production, prefer Stripe’s official library: stripe.webhooks.constructEvent(req.body, header, secret). It performs the same t=...,v1=... parse, timestamp tolerance check, and timing-safe compare in one call.
GitHub: Repository Automation
GitHub sends webhook events for repository activity. Common use cases include triggering deployments on push, running checks on pull requests, and syncing issue trackers.
What GitHub sends: JSON payload with the event type in the X-GitHub-Event header (not in the body). The delivery ID is in X-GitHub-Delivery. GitHub signs requests with HMAC-SHA256 in the X-Hub-Signature-256 header using a sha256=<hex> format.
1. Define the webhook:
import { createWebhook } from "@ironflow/node";import crypto from "crypto";
const githubWebhook = createWebhook({ id: "github", verify: (req) => { const signature = req.headers["x-hub-signature-256"]; if (!signature) throw new Error("Missing X-Hub-Signature-256 header");
const expected = "sha256=" + crypto .createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET!) .update(req.body) .digest("hex");
const a = Buffer.from(signature); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { throw new Error("Invalid signature"); } }, transform: (payload: any) => ({ // GitHub puts the event type in the X-GitHub-Event header (not in the // payload), and `action` further qualifies it (e.g. opened/closed for // pull_request). The SDK's transform() only sees the parsed payload, so // we name events off `action` here — set `event_prefix=github` and rely // on payload `action` where present. name: `github.${payload.action || "event"}`, data: payload, // The unique delivery ID lives in the X-GitHub-Delivery header, which // transform() cannot see. For push events the head commit SHA is a // reasonable per-delivery key; pick a payload field that is stable per // delivery for the event types you handle. idempotencyKey: payload.head_commit?.id ?? payload.pull_request?.id?.toString() ?? payload.issue?.id?.toString(), }),});2. Write the function:
import { createFunction } from "@ironflow/node";
const deployOnPush = createFunction( { id: "deploy-on-push", triggers: [{ event: "github.push" }], }, async ({ event, step }) => { const push = event.data as { ref: string; repository: { full_name: string }; head_commit: { id: string; message: string }; };
// Only deploy from main branch if (push.ref !== "refs/heads/main") { return { skipped: true, reason: "not main branch" }; }
const buildResult = await step.run("build", async () => { // Trigger your build pipeline return { buildId: `build-${push.head_commit.id.slice(0, 7)}` }; });
await step.run("deploy", async () => { // Deploy the build return { deployed: true, buildId: buildResult.buildId }; });
await step.run("notify-slack", async () => { // Notify the team return { notified: true }; });
return { deployed: true, commit: push.head_commit.id }; },);3. Wire it up:
import { serve } from "@ironflow/node";
export default serve({ functions: [deployOnPush], webhooks: [githubWebhook], serverUrl: process.env.IRONFLOW_URL,});1. Define the webhook:
var githubWebhook = ironflow.CreateWebhook(ironflow.WebhookConfig{ ID: "github", Verify: func(req *ironflow.WebhookRequest) error { sig := req.Header.Get("X-Hub-Signature-256") if sig == "" { return fmt.Errorf("missing X-Hub-Signature-256 header") } mac := hmac.New(sha256.New, []byte(os.Getenv("GITHUB_WEBHOOK_SECRET"))) mac.Write(req.Body) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(sig), []byte(expected)) { return fmt.Errorf("invalid signature") } return nil }, Transform: func(payload []byte) (*ironflow.WebhookEvent, error) { // The SDK's Transform receives the raw payload bytes only — it does // not see request headers, so X-GitHub-Event and X-GitHub-Delivery // are unavailable here. Name events off `action` and pick a stable // per-delivery field from the payload as the idempotency key. var event struct { Action string `json:"action"` HeadCommit struct { ID string `json:"id"` } `json:"head_commit"` PullRequest struct { ID int64 `json:"id"` } `json:"pull_request"` Issue struct { ID int64 `json:"id"` } `json:"issue"` } if err := json.Unmarshal(payload, &event); err != nil { return nil, err } action := event.Action if action == "" { action = "event" } idem := event.HeadCommit.ID if idem == "" && event.PullRequest.ID != 0 { idem = fmt.Sprintf("pr-%d", event.PullRequest.ID) } if idem == "" && event.Issue.ID != 0 { idem = fmt.Sprintf("issue-%d", event.Issue.ID) } return &ironflow.WebhookEvent{ Name: "github." + action, Data: payload, IdempotencyKey: idem, }, nil },})2. Write the function:
var deployOnPush = ironflow.CreateFunction(ironflow.FunctionConfig{ ID: "deploy-on-push", Triggers: []ironflow.Trigger{{Event: "github.push"}},}, func(ctx ironflow.Context) (any, error) { var push struct { Ref string `json:"ref"` HeadCommit struct { ID string `json:"id"` Message string `json:"message"` } `json:"head_commit"` } ctx.Event.Data(&push)
if push.Ref != "refs/heads/main" { return map[string]any{"skipped": true, "reason": "not main branch"}, nil }
buildResult, err := ironflow.Run(ctx, "build", func() (any, error) { return map[string]any{"buildId": "build-" + push.HeadCommit.ID[:7]}, nil }) if err != nil { return nil, err }
_, err = ironflow.Run(ctx, "deploy", func() (any, error) { return map[string]any{"deployed": true, "buildId": buildResult}, nil }) if err != nil { return nil, err }
return map[string]any{"deployed": true, "commit": push.HeadCommit.ID}, nil})3. Wire it up:
handler := ironflow.Serve(ironflow.ServeConfig{ Functions: []ironflow.Function{deployOnPush}, Webhooks: []ironflow.Webhook{githubWebhook}, ServerURL: os.Getenv("IRONFLOW_URL"),})GitHub event types & idempotency
GitHub puts the event type in the X-GitHub-Event header and the per-delivery UUID in X-GitHub-Delivery — neither is in the payload body. The current SDK only passes the parsed payload to transform(), so the example uses payload.action for naming and a payload-derived key (head_commit.id, pull_request.id, issue.id) for idempotency. If you need the GitHub delivery UUID, mount your own route around createWebhook and inject it before calling Ironflow, or rely on the event-type-specific keys shown.
Twilio: SMS and Communications
Twilio sends status callbacks when message delivery states change. Common use cases include tracking delivery receipts, handling inbound messages, and updating communication logs.
What Twilio sends: Form-encoded or JSON payload with fields like MessageSid, MessageStatus, and To. Twilio authenticates requests using a signature in the X-Twilio-Signature header computed over the full URL and sorted POST parameters. For simplicity, this example uses a shared auth token for HMAC verification.
1. Define the webhook:
import { createWebhook } from "@ironflow/node";import crypto from "crypto";
const twilioWebhook = createWebhook({ id: "twilio", verify: (req) => { const signature = req.headers["x-twilio-signature"]; if (!signature) throw new Error("Missing X-Twilio-Signature header");
// Simplified HMAC verification using auth token const expected = crypto .createHmac("sha1", process.env.TWILIO_AUTH_TOKEN!) .update(req.body) .digest("base64");
if (signature !== expected) { throw new Error("Invalid Twilio signature"); } }, transform: (payload: any) => ({ name: `twilio.${(payload.MessageStatus || payload.EventType || "message").toLowerCase()}`, data: { messageSid: payload.MessageSid, from: payload.From, to: payload.To, body: payload.Body, status: payload.MessageStatus, }, idempotencyKey: payload.MessageSid ? `${payload.MessageSid}-${payload.MessageStatus}` : undefined, }),});2. Write the function:
import { createFunction } from "@ironflow/node";
const handleSmsStatus = createFunction( { id: "handle-sms-status", triggers: [ { event: "twilio.delivered" }, { event: "twilio.failed" }, { event: "twilio.undelivered" }, ], }, async ({ event, step }) => { const sms = event.data as { messageSid: string; to: string; status: string; };
await step.run("update-message-log", async () => { // Update the message status in your database return { messageSid: sms.messageSid, status: sms.status }; });
if (sms.status === "failed" || sms.status === "undelivered") { await step.run("alert-on-failure", async () => { // Notify support about the failed delivery return { alerted: true, to: sms.to }; }); }
return { processed: true, messageSid: sms.messageSid }; },);3. Wire it up:
import { serve } from "@ironflow/node";
export default serve({ functions: [handleSmsStatus], webhooks: [twilioWebhook], serverUrl: process.env.IRONFLOW_URL,});1. Define the webhook:
var twilioWebhook = ironflow.CreateWebhook(ironflow.WebhookConfig{ ID: "twilio", Verify: func(req *ironflow.WebhookRequest) error { sig := req.Header.Get("X-Twilio-Signature") if sig == "" { return fmt.Errorf("missing X-Twilio-Signature header") } mac := hmac.New(sha1.New, []byte(os.Getenv("TWILIO_AUTH_TOKEN"))) mac.Write(req.Body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) if sig != expected { return fmt.Errorf("invalid Twilio signature") } return nil }, Transform: func(payload []byte) (*ironflow.WebhookEvent, error) { // Twilio status callbacks default to application/x-www-form-urlencoded. // Parse the form first; fall back to JSON if you've configured Twilio // to send JSON. var ( messageSid string messageStatus string eventType string from string to string body string ) if values, err := url.ParseQuery(string(payload)); err == nil && values.Get("MessageSid") != "" { messageSid = values.Get("MessageSid") messageStatus = values.Get("MessageStatus") eventType = values.Get("EventType") from = values.Get("From") to = values.Get("To") body = values.Get("Body") } else { var msg struct { MessageSid string `json:"MessageSid"` MessageStatus string `json:"MessageStatus"` EventType string `json:"EventType"` From string `json:"From"` To string `json:"To"` Body string `json:"Body"` } if err := json.Unmarshal(payload, &msg); err != nil { return nil, err } messageSid = msg.MessageSid messageStatus = msg.MessageStatus eventType = msg.EventType from = msg.From to = msg.To body = msg.Body }
status := messageStatus if status == "" { status = eventType } if status == "" { status = "message" } data, _ := json.Marshal(map[string]string{ "messageSid": messageSid, "from": from, "to": to, "body": body, "status": messageStatus, }) idempotencyKey := "" if messageSid != "" { idempotencyKey = messageSid + "-" + messageStatus } return &ironflow.WebhookEvent{ Name: "twilio." + strings.ToLower(status), Data: data, IdempotencyKey: idempotencyKey, }, nil },})2. Write the function:
var handleSmsStatus = ironflow.CreateFunction(ironflow.FunctionConfig{ ID: "handle-sms-status", Triggers: []ironflow.Trigger{ {Event: "twilio.delivered"}, {Event: "twilio.failed"}, {Event: "twilio.undelivered"}, },}, func(ctx ironflow.Context) (any, error) { var sms struct { MessageSid string `json:"messageSid"` To string `json:"to"` Status string `json:"status"` } ctx.Event.Data(&sms)
_, err := ironflow.Run(ctx, "update-message-log", func() (any, error) { return map[string]any{"messageSid": sms.MessageSid, "status": sms.Status}, nil }) if err != nil { return nil, err }
if sms.Status == "failed" || sms.Status == "undelivered" { _, err = ironflow.Run(ctx, "alert-on-failure", func() (any, error) { return map[string]any{"alerted": true, "to": sms.To}, nil }) if err != nil { return nil, err } }
return map[string]any{"processed": true, "messageSid": sms.MessageSid}, nil})3. Wire it up:
handler := ironflow.Serve(ironflow.ServeConfig{ Functions: []ironflow.Function{handleSmsStatus}, Webhooks: []ironflow.Webhook{twilioWebhook}, ServerURL: os.Getenv("IRONFLOW_URL"),})Twilio idempotency
Twilio can send the same status callback multiple times. Using MessageSid-MessageStatus as the idempotency key ensures each unique status transition is processed exactly once — a message moving from sent to delivered creates two distinct events, but duplicate delivered callbacks are deduplicated.
Twilio body format
Twilio status callbacks default to application/x-www-form-urlencoded. The Node SDK calls JSON.parse(body) before invoking transform(), so form-encoded payloads will fail with TRANSFORM_FAILED. Either configure your Twilio webhook to send JSON in the Twilio console, or front the SDK with a small adapter route that re-encodes form bodies as JSON. The Go example above handles both via url.ParseQuery fallback because Go’s Transform receives raw bytes.
Common Patterns
These patterns apply across all webhook integrations:
Always use idempotency keys. Most providers include a unique event ID in their payloads (id for Stripe, X-GitHub-Delivery for GitHub, MessageSid for Twilio). Return it from transform() as idempotencyKey to prevent duplicate processing when providers retry.
Namespace events by provider. Use a consistent prefix like stripe., github., twilio. so you can write targeted function triggers and avoid event name collisions across providers.
Keep verify and transform simple. The verify() function should only validate the signature — don’t make external API calls. The transform() function should extract and reshape the payload — don’t perform business logic. Business logic belongs in the function handler where it benefits from durable steps and retry.
Handle multiple event types. A single webhook source often sends many event types. Write separate functions for each type you care about rather than one function with a giant switch statement:
// Good: separate functions per event typeconst onPaymentSucceeded = createFunction({ id: "on-payment-succeeded", triggers: [{ event: "stripe.payment_intent.succeeded" }],}, async ({ event, step }) => { /* ... */ });
const onPaymentFailed = createFunction({ id: "on-payment-failed", triggers: [{ event: "stripe.payment_intent.payment_failed" }],}, async ({ event, step }) => { /* ... */ });Monitoring with the Dashboard
Once webhooks are flowing, the Ironflow dashboard gives you visibility into every provider without touching code or the CLI.
Webhooks page — lists all registered webhook sources. You can create new sources directly from the UI by filling in the provider ID, event prefix, and optional signature verification settings. This is useful for adding a provider quickly in development or for ops teams that manage sources without deploying code changes.
Webhook Deliveries page — shows every incoming webhook delivery for a given source. Each row displays the status (accepted, deduplicated, rejected, failed), external ID, timestamp, and linked event ID. You can:
- Filter by status to find failures — e.g., show only
rejecteddeliveries to diagnose signature verification issues with a new provider - Expand a delivery to inspect the full request headers and body — useful for debugging transform logic or understanding exactly what a provider sent
- Cross-reference events — click through from a delivery to the event it created, then to the function runs it triggered
Typical debugging workflow: When a webhook integration isn’t working as expected, open the Deliveries page filtered to the provider. If deliveries show rejected, the signature verification is failing — check the secret. If deliveries show accepted but no runs are triggered, the event name doesn’t match any function trigger — check the event prefix and payload type field. The raw payload in the delivery detail view shows exactly what the provider sent, so you can verify your transform() logic against real data.