> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myproceeds.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed HTTP callbacks when payments, paywalls, and services change in your account.

Webhooks push signed HTTP callbacks to your server when events happen in your account — payments, paywall and service changes. Verify each request with the `X-Webhook-Signature` header. Your backend can grant access, update a ledger, or alert your team without polling [Transactions](/dashboard/transactions) or [Events](/dashboard/events).

<Note>
  Webhooks are configured from the dashboard only. There is no API to create or manage endpoints.
</Note>

## Add an endpoint

In the dashboard, open **Webhooks** and click **Add endpoint**:

| Field              | Description                                                                                       |
| ------------------ | ------------------------------------------------------------------------------------------------- |
| **Endpoint URL**   | The HTTPS URL Proceeds sends events to — for example `https://api.example.com/webhooks/proceeds`. |
| **Description**    | Optional label to identify the endpoint.                                                          |
| **Events to send** | The event types this endpoint receives. Use **Select all** or pick individually.                  |

You can configure up to **20 endpoints**. Each endpoint can subscribe to a different set of events.

## Events

Choose which events each endpoint receives:

| Group           | Event                  | Fires when                                                                                       |
| --------------- | ---------------------- | ------------------------------------------------------------------------------------------------ |
| **Payments**    | `payment.requested`    | A `402` challenge was issued for a request.                                                      |
|                 | `payment.pending`      | A Circle Nanopayment authorization was accepted into a Gateway batch (transaction is `Pending`). |
|                 | `payment.succeeded`    | Payment settled and the protected request was proxied (`Completed`).                             |
|                 | `payment.failed`       | Settlement or fulfillment failed.                                                                |
| **Paywalls**    | `paywall.created`      | A paywall was created.                                                                           |
|                 | `paywall.updated`      | A paywall was updated.                                                                           |
|                 | `paywall.deleted`      | A paywall was deleted.                                                                           |
| **Services**    | `service.created`      | A service was created.                                                                           |
|                 | `service.updated`      | A service was updated.                                                                           |
|                 | `service.deleted`      | A service was deleted.                                                                           |
| **Withdrawals** | `withdrawal.initiated` | The withdrawal was initiated.                                                                    |
|                 | `withdrawal.burned`    | Tokens burned.                                                                                   |
|                 | `withdrawal.attested`  | Attestation received.                                                                            |
|                 | `withdrawal.minted`    | Tokens minted.                                                                                   |
|                 | `withdrawal.completed` | The withdrawal completed.                                                                        |
|                 | `withdrawal.failed`    | The withdrawal failed.                                                                           |

<Note>
  Webhook `payment.*` names follow transaction state (`Pending` / `Completed` / `Failed`). They are not the dashboard Events `REQUEST` / `SUCCESS` / `ERROR` stream, and `GET /v1/events` is not a webhook payload.
</Note>

## Verify each delivery

Verify `X-Webhook-Signature` with the endpoint's full `whsec_` secret before you grant access or update a ledger. Each endpoint has its own secret, shown in the endpoint list. Store it server-side.

|               |                                                                |
| ------------- | -------------------------------------------------------------- |
| Signed string | `{timestamp}.{rawBody}`                                        |
| Algorithm     | HMAC-SHA256, lowercase hex                                     |
| Header        | `X-Webhook-Signature: t={timestamp},v1={hex}`                  |
| Key           | full `whsec_…` (do not strip). Format `/^whsec_[0-9a-f]{64}$/` |

Pass the raw POST body, the `X-Webhook-Signature` header, and the endpoint secret into `verifyProceedsWebhook` (or the Python equivalent).

<CodeGroup>
  ```typescript Node.js theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto'

  export function parseHeader(
    signatureHeader: string | undefined,
  ): { t: string; v1: string } | null {
    if (typeof signatureHeader !== 'string' || !signatureHeader) return null
    const parts: Record<string, string> = Object.create(null)
    for (const segment of signatureHeader.split(',')) {
      const eq = segment.indexOf('=')
      if (eq <= 0) continue
      parts[segment.slice(0, eq).trim()] = segment.slice(eq + 1).trim()
    }
    if (!parts.t || !parts.v1) return null
    return { t: parts.t, v1: parts.v1 }
  }

  function hexToBuffer(hex: string): Buffer | null {
    if (!hex || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null
    try {
      return Buffer.from(hex, 'hex')
    } catch {
      return null
    }
  }

  export function verifyProceedsWebhook({
    rawBody,
    signatureHeader,
    secret,
  }: {
    rawBody: Buffer | string
    signatureHeader: string | undefined
    secret: string
  }): boolean {
    if (!secret.startsWith('whsec_')) {
      throw new TypeError('secret must start with whsec_')
    }
    const parsed = parseHeader(signatureHeader)
    if (!parsed) return false
    const provided = hexToBuffer(parsed.v1)
    if (!provided) return false
    const bodyUtf8 = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : String(rawBody)
    const expectedHex = createHmac('sha256', secret)
      .update(`${parsed.t}.${bodyUtf8}`, 'utf8')
      .digest('hex')
    const expected = Buffer.from(expectedHex, 'hex')
    if (provided.length !== expected.length) return false
    return timingSafeEqual(provided, expected)
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac


  def verify_proceeds_webhook(
      *,
      raw_body: bytes | str,
      signature_header: str | None,
      secret: str,
  ) -> bool:
      if not secret.startswith("whsec_"):
          raise TypeError("secret must start with whsec_")
      if not isinstance(signature_header, str) or not signature_header:
          return False

      parts: dict[str, str] = {}
      for segment in signature_header.split(","):
          eq = segment.find("=")
          if eq <= 0:
              continue
          key = segment[:eq].strip()
          value = segment[eq + 1 :].strip()
          if key:
              parts[key] = value

      t = parts.get("t")
      v1 = parts.get("v1")
      if not t or not v1:
          return False

      body_utf8 = (
          raw_body.decode("utf-8")
          if isinstance(raw_body, (bytes, bytearray))
          else str(raw_body)
      )
      signed_string = f"{t}.{body_utf8}".encode("utf-8")
      expected = hmac.new(secret.encode("utf-8"), signed_string, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, v1.lower())
  ```
</CodeGroup>

<Note>
  Proceeds signs the **raw** request body. If your framework parses JSON first (`express.json()`, `req.json()`, and similar), verification fails. Use the unparsed bytes. The HMAC key is the full `whsec_…` secret — do not strip the prefix.
</Note>

### Verify manually

Use these steps if you are not pasting the snippet above.

Proceeds sends a signature header like this (shown with newlines for clarity; the real header is one line):

```text theme={null}
X-Webhook-Signature:
t=1700000000,
v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

1. **Extract the timestamp and signature from the header.** Split on `,`, then `=`. `t` is the unix-seconds timestamp. `v1` is the hex HMAC. Ignore any other schemes.
2. **Prepare the signed payload.** Concatenate: timestamp (as a string) + `.` + the raw JSON body (exact POST bytes as utf8).
3. **Compute the expected signature.** HMAC-SHA256 of that string, using the full `whsec_…` secret as the key. Encoding is lowercase hex.
4. **Compare.** Constant-time compare (`timingSafeEqual` / `hmac.compare_digest`) of the hex-decoded buffers. Reject on mismatch or a malformed header.

<Note>
  There is **no** timestamp skew or replay window in Proceeds today. Do not implement a fake 5-minute check. Dedupe on envelope `id`.
</Note>

## Delivery and retries

Proceeds expects a `2xx` within **10 seconds**. Non-`2xx` responses retry up to **3 attempts**, with backoff **2s / 4s / 8s**. Retryable status codes: `408`, `429`, `5xx`. Other `4xx` responses are not retried.

Open an endpoint's **Recent deliveries** to inspect history:

| Column   | Meaning                                             |
| -------- | --------------------------------------------------- |
| Event    | The event type delivered.                           |
| Status   | `Succeeded` or failed.                              |
| Code     | The HTTP status your endpoint returned.             |
| Attempts | Delivery attempts used, out of 3.                   |
| Time     | When the delivery was sent.                         |
| Resend   | Dashboard replay of that delivery (not Resend.com). |

## Manage endpoints

The Webhooks list shows each endpoint's **Status** (active or paused), subscribed **Events**, **Last delivery**, and signing **Secret**. From there you can pause, edit, or delete an endpoint.

## Best practices

* Return `2xx` after verify and de-dupe; process asynchronously (10s timeout).
* De-dupe on envelope `id`.
* Subscribe narrowly — only enable the events each endpoint needs.

<CardGroup cols={2}>
  <Card title="Transactions" icon="receipt" href="/dashboard/transactions">
    The payment state that `payment.*` events mirror.
  </Card>

  <Card title="Events" icon="list-timeline" href="/dashboard/events">
    The request lifecycle you can also inspect in the dashboard.
  </Card>
</CardGroup>
