# Affonso Webhooks

## When to Use This Skill

* How do I receive Affonso webhooks?
* How do I verify the `X-Affonso-Signature` header (and `X-Affonso-Timestamp`)?
* How do I handle `referral.converted`, `transaction.created`, or `payout.paid` events?
* Why is my Affonso webhook signature verification failing?
* Why does verification fail when I hex-decode the Affonso signing secret?

## Essential Code (USE THIS)

Affonso sends two headers with every webhook:

```
X-Affonso-Timestamp: 1704067200
X-Affonso-Signature: 4f2c8a1b9d3e7f0a6c5b8d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a

```

* `X-Affonso-Signature` is the **bare lowercase hex** HMAC-SHA256 (64 characters, no `sha256=` prefix) of `"<X-Affonso-Timestamp>.<raw body>"`.
* The key is the endpoint's **signing secret** (a 64-character hex string), used **as a plain string. Do not hex-decode it.** It is shown once when you create the endpoint.
* Sign the header value exactly as received, then `.`, then the raw body bytes. Never `JSON.parse` / re-serialise first.
* The event name is the `type` field in the JSON body (`affiliate.created`, `transaction.paid`, ...).

### Verification (Node.js)

```javascript
const crypto = require('crypto');

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyAffonso(rawBody, timestampHeader, signatureHeader, secret) {
  if (!secret || !timestampHeader || !signatureHeader) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signatureHeader)) return false; // validate before decoding
  // Optional replay window omitted here (off by default): see "Replay window (opt-in)" below and the examples
  const signed = Buffer.concat([Buffer.from(timestampHeader, 'latin1'), Buffer.from('.'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(signatureHeader, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

```

### Verification (Python)

```python
import hashlib, hmac, re

def verify_affonso(raw_body: bytes, timestamp_header, signature_header, secret: str) -> bool:
    if not secret or not timestamp_header or not signature_header:
        return False
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature_header):  # validate before decoding
        return False
    signed = timestamp_header.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature_header), expected)

```

Respond `401` when verification fails (missing or empty header, malformed digest, mismatch).

### Replay window (opt-in)

Affonso's docs sample rejects timestamps more than 300 seconds from now. The examples leave that **off by default**: Affonso's own retries (11 attempts over 48 hours) and Hookdeck retries and replays resend the original `X-Affonso-Timestamp`, so a fixed window would reject legitimate redeliveries. Set `AFFONSO_WEBHOOK_TOLERANCE_SECONDS` to a positive number (the timestamp is Unix seconds) to opt in; unset or `0` is off. Use the event `id` for idempotency instead.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/affonso-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/affonso-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/affonso-webhooks/examples/fastapi/).

## Common Event Types

Dispatch on the body's `type`. Choose the events to send when you create the endpoint.

| Event | Description |
| --- | --- |
| `affiliate.created` | A new affiliate signs up (usually `PENDING`) |
| `affiliate.confirmed` | An affiliate is confirmed (`ACTIVE`) |
| `referral.lead` | A visitor referred by an affiliate becomes a lead |
| `referral.converted` | A referral converts to a customer |
| `transaction.created` | A sale and its commission are recorded |
| `transaction.approved` | A commission is approved |
| `transaction.paid` | A commission is paid |
| `transaction.rejected` | A commission is rejected (for example after a refund) |
| `payout.paid` | A payout to an affiliate completes |
| `payout.failed` | A payout fails |

Others: `affiliate.updated`, `affiliate.deleted`, `referral.updated`, `referral.deleted`, `transaction.updated`, `transaction.deleted`, `payout.created`, `payout.updated`, `payout.deleted`, `coupon.created`, `coupon.deleted`. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/affonso-webhooks/references/overview.md).

> **For the full event reference**, see [Affonso's webhook events](https://docs.affonso.io/webhooks).

## Important Headers

| Header | Description |
| --- | --- |
| `X-Affonso-Signature` | Lowercase hex HMAC-SHA256 of `<timestamp>.<raw body>`, no prefix |
| `X-Affonso-Timestamp` | Unix timestamp in seconds, part of the signed content |

## Environment Variables

```bash
AFFONSO_WEBHOOK_SECRET=your_64_character_hex_signing_secret   # Settings → Webhooks, shown once
AFFONSO_WEBHOOK_TOLERANCE_SECONDS=0   # optional; a positive value opts in to the replay window

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 affonso --path /webhooks/affonso

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/affonso-webhooks/references/overview.md) - Affonso webhook concepts, events, payloads, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/affonso-webhooks/references/setup.md) - Creating an endpoint and finding the signing secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/affonso-webhooks/references/verification.md) - Signature verification details and gotchas