React to a Webhook
End-to-end recipe for a webhook receiver that listens for artifact.status_changed events and kicks off a derivative agent run whenever an artifact is flipped to saved. This is the canonical "observe and follow up" automation pattern on the platform.
You will: register a webhook, stand up a Node.js receiver with HMAC verification, fan out to agents.run when the event matches, and verify with the built-in test-fire endpoint.
Prerequisites
- An API key with scopes
webhooks:write,workflows:write, andassets:read. See authentication.md and scopes. - A publicly reachable HTTPS URL for the receiver. Use
ngrok http 3000or a Cloudflare Tunnel locally. - Node.js 20+ (for native
fetchand thecrypto.timingSafeEqualAPI).
export API_KEY="sk_live_your_key_here"Steps
1. Register the webhook
curl -sS -X POST "https://app.amdahl.co/api/platform/v1/webhooks" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-tunnel.example.com/hooks/amdahl",
"events": ["artifact.status_changed"],
"description": "Fan out saved artifacts into summary follow-ups"
}'Expected response:
{
"id": "wh_01L0...",
"url": "https://your-tunnel.example.com/hooks/amdahl",
"events": ["artifact.status_changed"],
"secret": "whsec_SQ7...",
"enabled": true,
"created_at": "2026-04-14T21:00:00.100Z"
}What this tells you: save the secret now. It is only returned on create; you cannot fetch it again. You will use it to verify the HMAC signature on every incoming delivery.
2. Implement the receiver
Full Express receiver. Captures the raw body (required for HMAC verification), checks the signature, returns 202 immediately, then processes the event asynchronously. This is the production pattern; anything slower than a sub-second acknowledgement risks retries.
// server.ts
import express from 'express'
import crypto from 'node:crypto'
const PORT = 3000
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!
const AMDAHL_URL = process.env.AMDAHL_URL!
const API_KEY = process.env.API_KEY!
const app = express()
// Capture raw body for signature verification. Do NOT use express.json()
// before this point, or the raw bytes are lost.
app.use('/hooks/amdahl', express.raw({ type: 'application/json', limit: '1mb' }))
function verifySignature(raw: Buffer, header: string | undefined): boolean {
if (!header) return false
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(raw).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(header, 'hex')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
interface ArtifactStatusChanged {
event: 'artifact.status_changed'
data: {
artifact_id: string
artifact_type: string
previous_status: string
status: string
title: string
}
timestamp: string
}
app.post('/hooks/amdahl', (req, res) => {
const raw = req.body as Buffer
const sig = req.header('X-Webhook-Signature')
if (!verifySignature(raw, sig)) {
return res.status(401).json({ error: 'invalid_signature' })
}
// Acknowledge fast. The platform retries up to 3 times if this takes > 10s.
res.status(202).json({ received: true })
// Process async so a slow follow-up never blocks the ACK.
const event = JSON.parse(raw.toString('utf8')) as ArtifactStatusChanged
void handleEvent(event).catch(err => console.error('follow-up failed', err))
})
async function handleEvent(event: ArtifactStatusChanged) {
if (event.event !== 'artifact.status_changed') return
if (event.data.status !== 'saved') return
// Only fan out on the saved transition. Ignore draft or archived edits.
const res = await fetch(`https://app.amdahl.co/api/platform/v1/agents/run`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
profile_id: 'content_writer',
task: `Produce a 150-word executive summary of artifact ${event.data.artifact_id} (${event.data.title}).`,
input_params: {
channel: 'email',
content_type: 'summary',
source_artifact_id: event.data.artifact_id,
},
async: true,
}),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`agents.run failed ${res.status}: ${body}`)
}
const { session_id } = (await res.json()) as { session_id: string }
console.log('kicked off summary agent', session_id, 'for artifact', event.data.artifact_id)
}
app.listen(PORT, () => console.log(`webhook receiver on :${PORT}`))Start it:
WEBHOOK_SECRET=whsec_SQ7... AMDAHL_URL=https://app.amdahl.co API_KEY=$API_KEY \
npx tsx server.tsWhat this does: verifies every incoming request, returns 202 in under 10 ms, then fires a content_writer agent run when an artifact reaches saved. The second run produces its own artifact, which you can fetch with the pattern shown in draft-blog-post.md.
3. Fire a test delivery
The platform exposes a test-fire endpoint that sends a synthetic payload without affecting delivery stats. Use it end-to-end to confirm the receiver is wired correctly before relying on real events.
curl -sS -X POST "https://app.amdahl.co/api/platform/v1/webhooks/$WEBHOOK_ID/test" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event": "artifact.status_changed",
"payload": {
"artifact_id": "art_test_fake",
"artifact_type": "blog_post",
"previous_status": "draft",
"status": "saved",
"title": "Test artifact"
}
}'Expected response:
{
"delivery_id": "del_01M0...",
"status_code": 202,
"duration_ms": 88,
"success": true,
"error": null
}What this tells you: the platform reached your URL and got a 202. If status_code is 401, your HMAC check rejected the request (signature mismatch or missing X-Webhook-Signature header). If success=false with error='Network error', the URL is unreachable; check your tunnel.
4. Confirm the follow-up agent ran
In your receiver logs, you should see:
kicked off summary agent as_01M0... for artifact art_test_fakeFetch the status to confirm:
curl -sS "https://app.amdahl.co/api/platform/v1/agents/as_01M0.../status" \
-H "Authorization: Bearer $API_KEY"Within ~60s you should see status=complete and a result_artifact_id pointing at the summary.
5. Harden before production
Two things to add before you rely on this in production:
- Idempotency: the platform retries delivery up to 3 times with exponential backoff (1s, 10s, 60s). Persist
X-Webhook-Delivery-Idto a short-TTL store and ignore duplicates. - Dead-letter queue: any error in
handleEventsilently drops the work today. Pipe failures into a queue or logging sink so nothing disappears.
Minimal idempotency guard:
const seen = new Map<string, number>()
const TTL_MS = 15 * 60 * 1000
function markSeen(id: string): boolean {
const now = Date.now()
for (const [k, v] of seen) if (now - v > TTL_MS) seen.delete(k)
if (seen.has(id)) return false
seen.set(id, now)
return true
}Common issues
- All requests return
401 invalid_signature: you middleware-parsed JSON before capturing the raw body. Express.json()consumes the stream. Useexpress.raw()on the webhook route only. - Signature matches in your test but not in production: you are verifying against a different secret than the one returned at registration. Secrets are rotated only via delete-and-recreate; there is no rotation endpoint.
- Receiver times out on every retry: your handler is doing work before returning. Return
202synchronously, thenvoid handleEvent(...). - Duplicate follow-up agent runs: the platform retried because your response took longer than 10s or returned a non-2xx. Add the idempotency guard above.
- Events never arrive: the webhook row has
enabled=falseor a highfailure_count. Check viaGET /webhooks/:id. After 3 consecutive delivery failures the row is auto-disabled; re-enable withPATCH /webhooks/:id { "enabled": true }.
See also
- webhooks.md for the full webhook lifecycle.
- api-reference/webhook-events.md for the event catalog and payload shapes.
- api-reference/tools/workflows.md for
agents.run. - api-reference/tools/assets.md for fetching the source artifact inside your handler.
- recipes/draft-blog-post.md for the producer side that emits the
savedtransition this recipe consumes.