This template demonstrates how to build a secure webhook endpoint that validates requests using HMAC-SHA256 signatures and delegates processing to Render Workflows for reliable async execution.
Use case: A payment provider (like Stripe) sends a payment.succeeded webhook. The webhook service validates the signature, then triggers a workflow that:
- Updates order records (marks as paid)
- Sends a receipt email to the customer
- Notifies the fulfillment system
All without blocking the webhook response or managing queue infrastructure.
Payment Provider Render Platform
│ │
│ POST /webhook │
│ + signature header │
▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ Webhook Service │────────────▶│ Render Workflow │
│ │ trigger │ │
│ │ │ ┌───────────────┐ │
│ • Validate sig │ │ │process_payment│ │
│ • Check timestamp│ │ └───────┬───────┘ │
│ • Parse payload │ │ │ │
│ • Return 200 │ │ ┌─────┴─────┐ │
└─────────────────┘ │ ▼ ▼ │
│ update_ send_ │
│ records receipt │
│ │ │ │
│ └─────┬─────┘ │
│ ▼ │
│ notify_fulfillment│
└─────────────────────┘
This template includes both TypeScript and Python implementations:
webhook-workflows/
├── typescript/
│ ├── webhook/ # Fastify webhook receiver
│ │ ├── src/
│ │ │ ├── index.ts # Server setup and routes
│ │ │ ├── handlers.ts # Route handlers (business logic)
│ │ │ ├── config.ts # Environment configuration
│ │ │ ├── security.ts # HMAC signature validation
│ │ │ └── types.ts # Zod schemas for validation
│ │ └── package.json
│ └── workflows/ # Render Workflow tasks
│ ├── src/
│ │ └── main.ts # Task definitions
│ └── package.json
├── python/
│ ├── webhook/ # FastAPI webhook receiver
│ │ ├── main.py # Server setup and routes
│ │ ├── handlers.py # Route handlers (business logic)
│ │ ├── config.py # Environment configuration
│ │ ├── security.py # HMAC signature validation
│ │ ├── models.py # Pydantic models for validation
│ │ └── requirements.txt
│ └── workflows/ # Render Workflow tasks
│ ├── main.py # Task definitions
│ └── requirements.txt
├── frontend/ # React tester UI
│ └── ...
└── render.yaml # Blueprint for deployment
Click the button below to deploy the webhook service:
During deployment, you'll be prompted for:
- RENDER_API_KEY: Your Render API key (create one here)
- WORKFLOW_SLUG: Leave blank for now; set after creating the workflow
Workflows aren't yet supported in Blueprints, so create one manually:
- In the Render Dashboard, click New > Workflow
- Connect your repo (or fork of this template)
- Configure the workflow:
- Root Directory:
python/workflows(ortypescript/workflows) - Build Command:
pip install -r requirements.txt(ornpm install && npm run build) - Start Command:
python main.py(ornpm start)
- Root Directory:
- Click Deploy Workflow
After the workflow deploys:
- Go to the workflow's Tasks page
- Click on
process-paymentand copy the task slug (format:workflow-name/process-payment) - Go to your webhook service's Environment settings
- Set
WORKFLOW_SLUGto the copied task slug
This template implements industry-standard webhook security:
Every request must include:
X-Webhook-Signature:sha256=<hex_signature>X-Webhook-Timestamp:<unix_timestamp>
The signature is computed as:
HMAC-SHA256(key=WEBHOOK_SECRET, message=timestamp + "." + raw_body)
Requests with timestamps older than 5 minutes are rejected, preventing captured requests from being replayed later.
Signatures are compared using constant-time functions to prevent timing attacks.
The webhook service includes a built-in tester UI. Open your webhook URL in a browser to access it. The UI lets you:
- Generate signed test payloads
- Send requests to the webhook endpoint
- View real-time responses via SSE
# Set your webhook secret (from the Render Dashboard or local .env)
export WEBHOOK_SECRET="your-secret-here"
# Set your webhook URL
export WEBHOOK_URL="https://your-webhook.onrender.com/webhook"
# Generate timestamp and payload
TIMESTAMP=$(date +%s)
PAYLOAD='{"event_type":"payment.succeeded","event_id":"evt_test_'$(date +%s)'","timestamp":"2026-01-23T10:30:00Z","data":{"payment_id":"pi_test123","amount":11877,"currency":"usd","customer_email":"jane@example.com","customer_name":"Jane Smith","order_id":"ord_456","metadata":{"product_type":"subscription","plan":"pro"}}}'
# Compute signature
SIGNATURE=$(echo -n "${TIMESTAMP}.${PAYLOAD}" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | cut -d' ' -f2)
# Send the request
curl -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=${SIGNATURE}" \
-H "X-Webhook-Timestamp: ${TIMESTAMP}" \
-d "$PAYLOAD"{
"status": "processing",
"event_id": "evt_test_1706012345",
"task_run_id": "trn-abc123...",
"message": "Payment processing started (task: trn-abc123...)"
}- Go to your workflow in the Render Dashboard
- Click Tasks > process-payment > Runs
- Select the task run to see logs and results
You can run and test the full webhook-to-workflow pipeline locally using the Render CLI.
- Install the Render CLI (v2.11.0 or later)
- Node.js 20+ (for TypeScript) or Python 3.11+ (for Python)
The Render CLI runs a local task server that simulates the workflow execution lifecycle. In a terminal, start it with your workflow's start command:
TypeScript:
cd typescript/workflows
npm install && npm run build
render workflows dev -- npm startPython:
cd python/workflows
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
render workflows dev -- python main.pyThe task server starts on port 8120. To use a different port:
render workflows dev --port 9000 -- python main.pyIn a separate terminal, start the webhook service with local dev mode enabled:
TypeScript:
cd typescript/webhook
npm install
RENDER_USE_LOCAL_DEV=true npm run devPython:
cd python/webhook
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
RENDER_USE_LOCAL_DEV=true python main.pySetting RENDER_USE_LOCAL_DEV=true points the webhook service at your local task server instead of the Render API. You can also set this in a .env file:
WEBHOOK_SECRET=dev-secret-for-testing
RENDER_USE_LOCAL_DEV=true
# RENDER_LOCAL_DEV_URL=http://localhost:9000 # Only if using a non-default portcd frontend
npm install
npm run buildThe webhook service serves the built frontend automatically.
With the task server running, you can also list and run tasks directly from the CLI:
render workflows tasks list --local
render workflows taskruns start process_payment --local --input='[{"event_type":"payment.succeeded","event_id":"evt_test","timestamp":"2026-01-23T10:30:00Z","data":{"payment_id":"pi_123","amount":5000,"currency":"usd","customer_email":"jane@example.com","customer_name":"Jane Smith","order_id":"ord_456","metadata":{}}}]'Without RENDER_USE_LOCAL_DEV or a running task server, the webhook service validates signatures and payloads but skips workflow triggering. This is useful for testing webhook security logic in isolation.
For more details, see Local Development for Workflows.
The webhook expects this payload structure:
{
"event_type": "payment.succeeded",
"event_id": "evt_unique_id",
"timestamp": "2026-01-23T10:30:00Z",
"data": {
"payment_id": "pi_abc123",
"amount": 11877,
"currency": "usd",
"customer_email": "customer@example.com",
"customer_name": "Jane Smith",
"order_id": "ord_456",
"metadata": {
"product_type": "subscription",
"plan": "pro"
}
}
}| Field | Type | Description |
|---|---|---|
event_type |
string | payment.succeeded or payment.failed |
event_id |
string | Unique event ID for idempotency |
timestamp |
ISO 8601 | When the event occurred |
data.payment_id |
string | Unique payment identifier |
data.amount |
integer | Amount in cents |
data.currency |
string | ISO 4217 currency code (3 lowercase letters) |
data.customer_email |
string | Customer email address |
data.customer_name |
string | Customer full name |
data.order_id |
string | Associated order identifier |
data.metadata |
object | Optional key-value pairs |
| Variable | Required | Description |
|---|---|---|
WEBHOOK_SECRET |
Yes | Secret key for HMAC signature validation |
RENDER_API_KEY |
Yes* | Render API key for triggering workflows |
WORKFLOW_SLUG |
Yes* | Task slug (e.g., my-workflow/process-payment) |
*Required for workflow integration. The webhook validates requests without these but won't trigger tasks.
The workflow demonstrates several patterns:
update_records and send_receipt run simultaneously:
TypeScript:
const [recordsResult, receiptResult] = await Promise.all([
updateRecords(paymentId, orderId, amount, currency),
sendReceipt(paymentId, customerEmail, customerName, amount, currency, orderId),
]);Python:
records_result, receipt_result = await asyncio.gather(
update_records(payment_id, order_id, amount, currency),
send_receipt(payment_id, customer_email, customer_name, amount, currency, order_id),
)notify_fulfillment runs only after the parallel tasks complete.
notify_fulfillment has exponential backoff configured for transient failures:
TypeScript:
const notifyFulfillment = task(
{
name: "notify_fulfillment",
retry: {
maxRetries: 3,
waitDurationMs: 1000,
backoffScaling: 2.0, // 1s, 2s, 4s
},
},
async (orderId, paymentId, customerName, metadata) => { ... }
);Python:
@app.task(retry=Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2.0))
async def notify_fulfillment(order_id, payment_id, customer_name, metadata):
...