- Events & Pub/Sub
- Webhooks
- Use Cases
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
Section titled “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()The webhook definition is the only part that changes between providers. Once the event is emitted, Ironflow handles run retries and step memoization.
Note what this does not cover: Ironflow does not retry or replay the provider’s HTTP delivery itself. If your endpoint is down when Stripe posts, recovery is Stripe’s retry schedule, not Ironflow’s — so return quickly and let the durable function do the slow work.
Stripe: Payment Processing
Section titled “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.
The Stripe-Signature header is a comma-separated list — t=<unix-timestamp>,v1=<hex>,v0=<hex> — and the HMAC-SHA256 is computed over <timestamp>.<raw-body>, not over the body alone. Verify the v1 scheme only; v0 is a test-only signature and honoring it enables a downgrade attack. Because the timestamp is inside the signed payload, an attacker cannot alter it without breaking the signature, which is what makes the freshness check below meaningful.
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");
// Reject stale deliveries. Without this the signature stays valid forever, // so a captured request can be replayed indefinitely. Stripe's own // libraries default to a 5-minute tolerance; never use 0, which disables // the check entirely. const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t)); if (!Number.isFinite(ageSeconds) || ageSeconds > 300) { throw new Error("Stripe signature timestamp outside tolerance"); }
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_SERVER_URL,});1. Define the webhook:
package main
import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "os" "strconv" "strings" "time"
"github.com/sahina/ironflow-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") }
// Header is "t=<unix>,v1=<hex>,v0=<hex>". Take v1 only — v0 is a // test-only scheme and honoring it enables a downgrade attack. var timestamp, v1 string for _, part := range strings.Split(sig, ",") { k, v, found := strings.Cut(part, "=") if !found { continue } switch k { case "t": timestamp = v case "v1": v1 = v } } if timestamp == "" || v1 == "" { return fmt.Errorf("malformed Stripe signature") }
// Reject stale deliveries: without this a captured request replays // forever. Stripe's own libraries default to 5 minutes. ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return fmt.Errorf("malformed Stripe timestamp: %w", err) } if age := time.Since(time.Unix(ts, 0)); age > 5*time.Minute || age < -5*time.Minute { return fmt.Errorf("Stripe signature timestamp outside tolerance") }
// Stripe signs "<timestamp>.<raw body>", not the body alone. mac := hmac.New(sha256.New, []byte(os.Getenv("STRIPE_WEBHOOK_SECRET"))) fmt.Fprintf(mac, "%s.%s", timestamp, req.Body) expected := mac.Sum(nil)
got, err := hex.DecodeString(v1) if err != nil { return fmt.Errorf("malformed Stripe signature encoding: %w", err) } if subtle.ConstantTimeCompare(got, expected) != 1 { 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_SERVER_URL"),})
http.Handle("/api/ironflow", handler) // push-mode function invocationshttp.Handle("/webhooks/", handler) // the Go handler matches /webhooks/:provider as a path prefixhttp.ListenAndServe(":3000", nil)1. Define the webhook:
import hashlibimport hmacimport jsonimport osimport time
from ironflow.serve import Webhook, WebhookEvent, WebhookRequest
def verify_stripe(req: WebhookRequest) -> None: # serve() lowercases header names. sig = req.headers.get("stripe-signature") if not sig: raise ValueError("missing Stripe-Signature header")
# Header is "t=<unix>,v1=<hex>,v0=<hex>". Take v1 only — v0 is a # test-only scheme and honoring it enables a downgrade attack. parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p) timestamp, v1 = parts.get("t"), parts.get("v1") if not timestamp or not v1: raise ValueError("malformed Stripe signature")
# Reject stale deliveries: without this a captured request replays # forever. Stripe's own libraries default to 5 minutes. if abs(time.time() - int(timestamp)) > 300: raise ValueError("Stripe signature timestamp outside tolerance")
# Stripe signs "<timestamp>.<raw body>", not the body alone. secret = os.environ["STRIPE_WEBHOOK_SECRET"].encode() expected = hmac.new(secret, timestamp.encode() + b"." + req.body, hashlib.sha256).hexdigest() if not hmac.compare_digest(v1, expected): raise ValueError("invalid signature")
def transform_stripe(payload: bytes) -> WebhookEvent: event = json.loads(payload) return WebhookEvent( name=f"stripe.{event['type']}", data=event["data"]["object"], idempotency_key=event["id"], )
stripe_webhook = Webhook(id="stripe", verify=verify_stripe, transform=transform_stripe)2. Write the function:
from ironflow.worker import function
@function(id="fulfill-order", triggers=[{"event": "stripe.payment_intent.succeeded"}])async def fulfill_order(ctx): payment = ctx.event.data
status = await ctx.step.run( "update-order-status", lambda: {"orderId": payment["metadata"]["orderId"], "status": "paid"}, )
await ctx.step.run("send-confirmation", lambda: {"sent": True})
return {"fulfilled": True, "orderStatus": status}3. Wire it up:
import os
from ironflow.serve import serve
# One ASGI app serves push-mode invocations and /webhooks/{id}.app = serve([fulfill_order], webhooks=[stripe_webhook], server_url=os.environ["IRONFLOW_SERVER_URL"])GitHub: Repository Automation
Section titled “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`. push payloads carry no `action` but always // carry a `commits` array, so that shape maps to "push". name: `github.${payload.action || (payload.commits ? "push" : "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. // Dedup is per environment and per key, with no event-name qualifier, so // an entity id alone collapses distinct deliveries (opened/closed share a // PR id; repeated synchronize/labeled too). Add the ref or updated_at. idempotencyKey: payload.head_commit ? `push-${payload.ref}-${payload.head_commit.id}` : payload.pull_request ? `pr-${payload.pull_request.id}-${payload.action}-${payload.pull_request.updated_at}` : payload.issue ? `issue-${payload.issue.id}-${payload.action}-${payload.issue.updated_at}` : undefined, }),});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_SERVER_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` (push payloads have // none but always carry `commits`) and pick a stable per-delivery // field from the payload as the idempotency key. var event struct { Action string `json:"action"` Ref string `json:"ref"` Commits json.RawMessage `json:"commits"` HeadCommit struct { ID string `json:"id"` } `json:"head_commit"` PullRequest struct { ID int64 `json:"id"` UpdatedAt string `json:"updated_at"` } `json:"pull_request"` Issue struct { ID int64 `json:"id"` UpdatedAt string `json:"updated_at"` } `json:"issue"` } if err := json.Unmarshal(payload, &event); err != nil { return nil, err } action := event.Action if action == "" { action = "event" if len(event.Commits) > 0 { action = "push" } } // Dedup is per environment and per key with no event-name qualifier, // so an entity id alone collapses distinct deliveries. Add ref/updated_at. idem := "" if event.HeadCommit.ID != "" { idem = "push-" + event.Ref + "-" + event.HeadCommit.ID } if idem == "" && event.PullRequest.ID != 0 { idem = fmt.Sprintf("pr-%d-%s-%s", event.PullRequest.ID, action, event.PullRequest.UpdatedAt) } if idem == "" && event.Issue.ID != 0 { idem = fmt.Sprintf("issue-%d-%s-%s", event.Issue.ID, action, event.Issue.UpdatedAt) } 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_SERVER_URL"),})1. Define the webhook:
import hashlibimport hmacimport jsonimport os
from ironflow.serve import Webhook, WebhookEvent, WebhookRequest
def verify_github(req: WebhookRequest) -> None: sig = req.headers.get("x-hub-signature-256") if not sig: raise ValueError("missing X-Hub-Signature-256 header") secret = os.environ["GITHUB_WEBHOOK_SECRET"].encode() expected = "sha256=" + hmac.new(secret, req.body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): raise ValueError("invalid signature")
def transform_github(payload: bytes) -> WebhookEvent: # 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` (push payloads have none but always carry # `commits`) and pick a stable per-delivery field from the payload as the # idempotency key. event = json.loads(payload) # Dedup is per environment and per key with no event-name qualifier, so an # entity id alone collapses distinct deliveries. Add ref/updated_at. action = event.get("action") or ("push" if "commits" in event else "event") idem = None if (event.get("head_commit") or {}).get("id"): idem = f"push-{event.get('ref')}-{event['head_commit']['id']}" elif event.get("pull_request"): pr = event["pull_request"] idem = f"pr-{pr['id']}-{action}-{pr.get('updated_at')}" elif event.get("issue"): issue = event["issue"] idem = f"issue-{issue['id']}-{action}-{issue.get('updated_at')}" return WebhookEvent(name=f"github.{action}", data=event, idempotency_key=idem)
github_webhook = Webhook(id="github", verify=verify_github, transform=transform_github)2. Write the function:
from ironflow.worker import function
@function(id="deploy-on-push", triggers=[{"event": "github.push"}])async def deploy_on_push(ctx): push = ctx.event.data
if push["ref"] != "refs/heads/main": return {"skipped": True, "reason": "not main branch"}
commit = push["head_commit"]["id"] build_result = await ctx.step.run("build", lambda: {"buildId": f"build-{commit[:7]}"})
await ctx.step.run("deploy", lambda: {"deployed": True, "buildId": build_result})
return {"deployed": True, "commit": commit}3. Wire it up:
app = serve([deploy_on_push], webhooks=[github_webhook], server_url=os.environ["IRONFLOW_SERVER_URL"])Twilio: SMS and Communications
Section titled “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 request URL plus the POST parameters sorted alphabetically — not over the raw body.
That last point matters: unlike Stripe and GitHub, a Twilio signature cannot be checked from the body alone, because the URL is part of the signed material. This is deliberate — it stops an attacker replaying a valid request against a different endpoint. It also means you must pass the exact public URL Twilio called, including query string; behind a proxy that rewrites the host or scheme, reconstructing it from the inbound request will silently fail verification.
Use Twilio’s own validator rather than hand-rolling the canonicalization.
1. Define the webhook:
import { createWebhook } from "@ironflow/node";import twilio from "twilio";
const twilioWebhook = createWebhook({ id: "twilio", verify: (req) => { const signature = req.headers["x-twilio-signature"]; if (!signature) throw new Error("Missing X-Twilio-Signature header");
// Must be the exact public URL Twilio called, including query string. const url = process.env.TWILIO_WEBHOOK_URL!; const params = Object.fromEntries(new URLSearchParams(req.body));
// validateRequest handles the canonicalization (URL + alphabetically // sorted params) and the constant-time comparison. if (!twilio.validateRequest(process.env.TWILIO_AUTH_TOKEN!, signature, url, params)) { 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_SERVER_URL,});1. Define the webhook:
import ( "encoding/json" "fmt" "net/url" "os" "strings"
"github.com/twilio/twilio-go/client"
"github.com/sahina/ironflow-go/ironflow")
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") } // github.com/twilio/twilio-go/client validator := client.NewRequestValidator(os.Getenv("TWILIO_AUTH_TOKEN"))
// Must be the exact public URL Twilio called, including query string. form, err := url.ParseQuery(string(req.Body)) if err != nil { return fmt.Errorf("parse twilio form: %w", err) } params := make(map[string]string, len(form)) for k := range form { params[k] = form.Get(k) }
// Validate handles the canonicalization (URL + alphabetically sorted // params) and the constant-time comparison. if !validator.Validate(os.Getenv("TWILIO_WEBHOOK_URL"), params, sig) { 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_SERVER_URL"),})1. Define the webhook:
import jsonimport osfrom urllib.parse import parse_qsl
from twilio.request_validator import RequestValidator # pip install twilio
from ironflow.serve import Webhook, WebhookEvent, WebhookRequest
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
def verify_twilio(req: WebhookRequest) -> None: sig = req.headers.get("x-twilio-signature") if not sig: raise ValueError("missing X-Twilio-Signature header") # Must be the exact public URL Twilio called, including query string. # validate() handles the canonicalization (URL + alphabetically sorted # params) and the constant-time comparison. params = dict(parse_qsl(req.body.decode())) if not validator.validate(os.environ["TWILIO_WEBHOOK_URL"], params, sig): raise ValueError("invalid Twilio signature")
def transform_twilio(payload: bytes) -> WebhookEvent: # 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. msg = dict(parse_qsl(payload.decode())) if not msg.get("MessageSid"): msg = json.loads(payload)
status = msg.get("MessageStatus") or msg.get("EventType") or "message" sid = msg.get("MessageSid", "") return WebhookEvent( name=f"twilio.{status.lower()}", data={ "messageSid": sid, "from": msg.get("From", ""), "to": msg.get("To", ""), "body": msg.get("Body", ""), "status": msg.get("MessageStatus", ""), }, idempotency_key=f"{sid}-{msg.get('MessageStatus', '')}" if sid else None, )
twilio_webhook = Webhook(id="twilio", verify=verify_twilio, transform=transform_twilio)2. Write the function:
from ironflow.worker import function
@function( id="handle-sms-status", triggers=[{"event": "twilio.delivered"}, {"event": "twilio.failed"}, {"event": "twilio.undelivered"}],)async def handle_sms_status(ctx): sms = ctx.event.data
await ctx.step.run( "update-message-log", lambda: {"messageSid": sms["messageSid"], "status": sms["status"]} )
if sms["status"] in ("failed", "undelivered"): await ctx.step.run("alert-on-failure", lambda: {"alerted": True, "to": sms["to"]})
return {"processed": True, "messageSid": sms["messageSid"]}3. Wire it up:
app = serve([handle_sms_status], webhooks=[twilio_webhook], server_url=os.environ["IRONFLOW_SERVER_URL"])Common Patterns
Section titled “Common Patterns”These patterns apply across all webhook integrations:
Always carry the provider’s event ID. Most providers include a unique event ID in their payloads (id for Stripe, X-GitHub-Delivery for GitHub, MessageSid for Twilio). On the SDK path, transform() cannot read headers, so X-GitHub-Delivery is not reachable there — see the GitHub note above. Return it from transform() as idempotencyKey — on the SDK path Emit dedups on it, and a server-managed source (Path A) dedups on its configured dedup_id_path. Dedup is environment-scoped, and it only collapses duplicate events; a step with a side effect still needs step.run so it is memoized across retries of the same run.
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 }) => { /* ... */ });# Good: separate functions per event type@function(id="on-payment-succeeded", triggers=[{"event": "stripe.payment_intent.succeeded"}])async def on_payment_succeeded(ctx): ...
@function(id="on-payment-failed", triggers=[{"event": "stripe.payment_intent.payment_failed"}])async def on_payment_failed(ctx): ...Monitoring with the Dashboard
Section titled “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 choosing a provider preset and filling in the name, event prefix, and optional signature verification settings. The server generates the source ID. 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, rejected, failed), external ID, timestamp, and linked event ID. A retry of an already accepted delivery answers 200 {"status":"deduplicated"} and adds no row. 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 delivery’s error says why: a wrong ingest token, a failed signature check (check the secret and the signature settings), or an emit that a policy denied. 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.