# Outgoing webhooks

Tinyforge posts JSON events to a configured URL when deploys and static-site
syncs finish. Receivers can verify each request was sent by Tinyforge and
not tampered with by checking the **HMAC-SHA256** signature on the body.

## Tiers and resolution

A single global URL is rarely enough — different teams own different
projects, and operators often want to route prod failures somewhere noisier
than dev failures. Tinyforge supports four tiers:

| Tier      | Where set                              | Used for                |
|-----------|----------------------------------------|-------------------------|
| `stage`   | Stage edit form                         | Per-stage deploys       |
| `project` | Project edit form                       | All stages of a project |
| `site`    | Static-site detail page                 | Static-site sync events |
| `settings`| Settings → Integrations                 | Global fallback         |

Resolution order:

* **Deploys**: `stage → project → settings`
* **Sites**:   `site → settings`

The most-specific tier with a non-empty URL wins. The signing secret
travels with the URL that sourced it: a stage can sign even when the
project and global URLs are unsigned.

## Signature scheme

Every request includes:

```
POST /your/handler HTTP/1.1
Content-Type: application/json
User-Agent: Tinyforge-Webhook/1
X-Hub-Signature-256: sha256=<hex>
X-Tinyforge-Event: deploy_success
X-Tinyforge-Delivery: 0f3a…-uuid
X-Tinyforge-Timestamp: 2026-05-07T12:34:56Z
X-Tinyforge-Tier: stage
```

The signature is `HMAC-SHA256(secret, raw_body)`, hex-encoded, with the
`sha256=` prefix — the GitHub `X-Hub-Signature-256` format. Receivers
already built for GitHub-style webhooks (Gitea, Forgejo, n8n, Hookdeck,
the service-to-notification-bridge generic webhook provider) verify it
without modification.

When no signing secret is configured for the resolved tier, the
signature header is omitted and the request goes out unsigned. This is
intentional back-compat for receivers that don't speak HMAC.

## Receiver requirements

A correct verifier:

1. **Reads the raw body bytes** before any JSON parse / re-serialise.
2. Computes `HMAC-SHA256(secret, body)` and compares to the value after
   `sha256=` in `X-Hub-Signature-256`.
3. Uses a **constant-time** comparator (`hmac.compare_digest` /
   `crypto.timingSafeEqual` / `hmac.Equal`).
4. Returns 401/403 on mismatch — Tinyforge surfaces the receiver's
   status code in the UI when the operator clicks **Send test**.

### Node / TypeScript

```ts
import { createHmac, timingSafeEqual } from 'crypto';

export function verify(secret: string, rawBody: Buffer, header: string): boolean {
  const got = header.startsWith('sha256=') ? header.slice(7) : header;
  const want = createHmac('sha256', secret).update(rawBody).digest('hex');
  if (got.length !== want.length) return false;
  return timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(want, 'hex'));
}
```

### Python

```python
import hmac
import hashlib

def verify(secret: str, raw_body: bytes, header: str) -> bool:
    got = header[7:] if header.startswith("sha256=") else header
    want = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(got, want)
```

### Go

```go
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strings"
)

func verify(secret string, body []byte, header string) bool {
    got := strings.TrimPrefix(header, "sha256=")
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    want := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(got), []byte(want))
}
```

## Event payload

```json
{
  "type": "deploy_success",
  "project": "demo-app",
  "stage": "prod",
  "image_tag": "v1.4.2",
  "subdomain": "stage-prod-demo-app",
  "url": "https://stage-prod-demo-app.example.com",
  "timestamp": "2026-05-07T12:34:56Z"
}
```

`type` values:
- `deploy_success`, `deploy_failure` — sent by the deployer.
- `site_sync_success`, `site_sync_failure` — sent by the static-site manager.
  Use the `project` field as the site name; `stage` and `image_tag` are empty.
- `test` — sent by the **Send test** button. Treat it as a no-op or
  surface it in your operator log; never as a real deploy event.

## Configuring the service-to-notification-bridge

If you're sending Tinyforge events to the
[service-to-notification-bridge](https://github.com/) generic webhook
provider:

1. Create a **Generic Webhook** provider.
2. Set `auth_mode = hmac_sha256`.
3. Paste the **same secret** Tinyforge generated (revealed via the
   Outgoing webhook panel).
4. Set `event_type_path = type` so deploys and site syncs map to
   distinct event types in the bridge.
5. Add `payload_mappings` for `project`, `stage`, `image_tag`, `url`,
   `error` and reference them as `{{ extra.project }}` in your
   notification templates.

The bridge accepts `X-Hub-Signature-256` natively (no header rename
needed) and reads the raw body before parsing, so step 1 of the
receiver requirements is already met.

## Rotating secrets

Click **Regenerate** in the Outgoing webhook panel to rotate. The old
secret is invalidated immediately — update receivers in lock-step or
expect a brief window of 401s. There is no soft rollover today.

To send unsigned events to a legacy receiver that can't verify, click
**Disable signing**. Tinyforge will keep dispatching events without the
`X-Hub-Signature-256` header until you regenerate.
