> For the complete documentation index, see [llms.txt](https://docs.robotmoney.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.robotmoney.com/api-reference/webhooks-api.md).

# Webhooks

Inspect and replay failed deliveries on RobotMoney's outbound webhook endpoints. This is the operator-facing surface for dead-lettered events. The inbound provider webhook (`POST /v1/raw/webhooks/{provider}`) lives in [Sources & Raw Ingestion](/platform/sources-api.md).

| Operation                 | Endpoint                                      | Required scope |
| ------------------------- | --------------------------------------------- | -------------- |
| List dead-letter events   | `GET /v1/webhooks/{endpoint_id}/dead-letters` | `audit:read`   |
| Replay dead-letter events | `POST /v1/webhooks/{endpoint_id}/replay`      | `audit:write`  |

Both routes are tenant-isolated. The `endpoint_id` belongs to the calling tenant; a cross-tenant id returns `404`.

### Event Types

RobotMoney forwards a fixed allowlist of audit actions to registered endpoints. These are the only `event_type` values an outbound webhook carries:

| `event_type`                              | Fires when                                                    |
| ----------------------------------------- | ------------------------------------------------------------- |
| `agent.action.proposed`                   | An agent proposed an action                                   |
| `payment_intent.created`                  | A PaymentIntent is proposed                                   |
| `payment_intent.approved`                 | A required approval was recorded (or Policy said `allow`)     |
| `payment_intent.awaiting_second_approval` | A first approval landed and a distinct second approver is due |
| `proposal.awaiting_second_approval`       | Contract-named event for the awaiting-second-approval move    |
| `proposal.decided`                        | A surface proposal reached a terminal decision                |
| `payment_intent.rejected`                 | Policy or an approver rejected                                |
| `payment_intent.executed`                 | The intent was executed                                       |
| `payment_intent.failed`                   | Execution failed                                              |
| `payment_intent.reconciling`              | The intent was parked for reconciliation                      |
| `member.changed`                          | A member was created, changed, or deactivated                 |
| `payment_intent.execute.after`            | The §6 gate ran and the intent was dispatched to a rail       |
| `ledger.counterparty.created`             | A counterparty row was created                                |
| `ledger.counterparty.updated`             | A counterparty identity was edited                            |
| `ledger.transaction.created`              | A transaction row was created                                 |
| `ledger.obligation.created`               | An obligation row was created                                 |
| `policy.evaluate`                         | A policy decision was recorded                                |
| `raw.ingest.new`                          | A new Raw artifact was ingested                               |
| `raw.ingest.deduplicated`                 | A re-submitted artifact matched an existing one               |
| `raw.extraction.status_changed`           | A Raw extraction changed status                               |
| `raw.source.status_changed`               | A connected source changed status                             |

There is no `payment_intent.settled` event: rail settlement is async and confirmed via the rail-specific provider webhook plus the proof endpoint. `payment_intent.failed` **is** emitted when execution fails. The legacy bare `action.*` names are **not** emitted.

### How Dead-Lettering Works

RobotMoney dispatches webhook deliveries asynchronously. Each row in the dead-letter table tracks an `attempt_count`; the delivery worker retries with exponential backoff up to **5 attempts**, after which the row is marked exhausted and stops auto-retrying. Replay (below) is the manual escape hatch.

### List Dead-Letter Events

```http
GET /v1/webhooks/{endpoint_id}/dead-letters
Authorization: Bearer <token>
```

```json
{
  "endpoint_id": "wh_ops_alerts",
  "dead_letters": [
    {
      "id": "dl_001",
      "event_id": "audit_evt_xyz",
      "event_type": "payment_intent.execute.after",
      "last_error": "503 Service Unavailable",
      "attempt_count": 5,
      "created_at": "2026-05-27T08:15:00Z",
      "last_attempt_at": "2026-05-27T08:47:12Z"
    }
  ]
}
```

### Replay Dead-Letter Events

Re-attempts delivery for every dead-letter row that is still under the attempt cap. Successes clear the row; failures bump `attempt_count`. The operation is **idempotent**. It accepts an `Idempotency-Key` header and is safe to retry.

```http
POST /v1/webhooks/{endpoint_id}/replay
Authorization: Bearer <token>
Idempotency-Key: <stable-key>
```

```json
{
  "endpoint_id": "wh_ops_alerts",
  "attempted": 7,
  "redelivered": 5,
  "still_failing": 2
}
```

If `still_failing > 0`, those rows had their `attempt_count` bumped; once a row hits 5 attempts it stops being auto-replayed and you can only retry it via this manual route after fixing the receiver.

### What's Next

<table data-view="cards"><thead><tr><th></th><th></th><th data-type="content-ref"></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>📜 Audit API</strong></td><td>The events that drive outbound webhooks.</td><td><a href="/api-reference/audit-api.md">Audit API</a></td><td></td></tr><tr><td><strong>📥 Sources &#x26; Raw Ingestion</strong></td><td>The inbound webhook side (provider HMAC).</td><td><a href="/platform/sources-api.md">Sources and Raw Ingestion</a></td><td></td></tr></tbody></table>
