# React To Webhook

# 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](../authentication.md) and [scopes](../api-reference/scopes.md).
- 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](./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:

```
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

- [webhooks.md](../webhooks.md) for the full webhook lifecycle.
- [api-reference/webhook-events.md](../api-reference/webhook-events.md) for the event catalog and payload shapes.
- [api-reference/tools/workflows.md](../api-reference/tools/workflows.md) for `agents.run`.
- [api-reference/tools/assets.md](../api-reference/tools/assets.md) for fetching the source artifact inside your handler.
- [recipes/draft-blog-post.md](./draft-blog-post.md) for the producer side that emits the `saved` transition this recipe consumes.
