Docs

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, and assets:read. See authentication.md and scopes.
  • A publicly reachable HTTPS URL for the receiver. Use ngrok http 3000 or a Cloudflare Tunnel locally.
  • Node.js 20+ (for native fetch and the crypto.timingSafeEqual API).
bash
export API_KEY="sk_live_your_key_here"

Steps

1. Register the webhook

bash
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:

json
{
  "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.

ts
// 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:

bash
WEBHOOK_SECRET=whsec_SQ7... AMDAHL_URL=https://app.amdahl.co API_KEY=$API_KEY \
  npx tsx server.ts

What 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.

bash
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:

json
{
  "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:

code
kicked off summary agent as_01M0... for artifact art_test_fake

Fetch the status to confirm:

bash
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:

  1. Idempotency: the platform retries delivery up to 3 times with exponential backoff (1s, 10s, 60s). Persist X-Webhook-Delivery-Id to a short-TTL store and ignore duplicates.
  2. Dead-letter queue: any error in handleEvent silently drops the work today. Pipe failures into a queue or logging sink so nothing disappears.

Minimal idempotency guard:

ts
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. Use express.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 202 synchronously, then void 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=false or a high failure_count. Check via GET /webhooks/:id. After 3 consecutive delivery failures the row is auto-disabled; re-enable with PATCH /webhooks/:id { "enabled": true }.

See also