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

# Webhooks

> Receive real-time notifications when events occur in Moneyline.

## Setup

### Create a Webhook Endpoint

```bash theme={null}
curl -X POST https://api.moneyline.co/v1/webhooks \
  -H "Authorization: Bearer ml_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/moneyline",
    "events": ["submission.processed", "document.parsed", "policy.evaluated"]
  }'
```

### Dashboard

Navigate to **Webhooks** in the dashboard to create, edit, and monitor webhook endpoints.

## Events

| Event | Description |
| - | - |
| `submission.created` | New submission created |
| `submission.updated` | Submission fields changed |
| `submission.processed` | All documents processed, analytics ready |
| `submission.review_required` | Processing finished but needs a human look |
| `submission.failed` | Processing failed |
| `document.uploaded` | Document uploaded |
| `document.classified` | Document auto-classified |
| `document.parsed` | Document parsing complete |
| `document.failed` | Document processing failed |
| `transaction.enriched` | Transaction enrichment complete |
| `policy.evaluated` | Policy evaluation complete |
| `policy.decision` | Policy produced a decision |
| `funder.submitted` | Submission sent to a funder |
| `funder.response` | Funder responded |
| `integration.synced` | Integration sync completed |
| `integration.error` | Integration sync failed |

This is the whole list the API accepts — `events` is validated against it, so
subscribing to anything else is rejected at creation. Fetch it at runtime with
`GET /v1/webhooks/event-types` (`webhooks.listEventTypes()` in the SDKs) rather
than hard-coding it.

## Payload Format

```json theme={null}
{
  "id": "evt_abc123",
  "type": "submission.processed",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "id": "sub_xyz789",
    "external_id": "deal-001",
    "status": "processed",
    "documents_count": 3,
    "transactions_count": 245
  }
}
```

## Verification

Every delivery carries an HMAC-SHA256 signature in the `X-Moneyline-Signature`
header:

```
X-Moneyline-Signature: t=1788269420,v1=3f8a...c21
```

`v1` is HMAC-SHA256 over **`<t>.<raw body>`**, keyed with the endpoint's signing
secret. Two things matter and are easy to get wrong:

* **The timestamp is part of the signed string.** Signing the body alone lets
  anyone who captures one delivery replay it forever, because nothing in the
  signature says when it was made. Reject deliveries whose `t` is outside a
  tolerance window — five minutes is a reasonable default.
* **Verify the raw bytes.** The signature covers exactly what was sent, so a
  handler that parses the body first has discarded the only thing it can check.
  Re-serializing a parsed object will not verify even for a genuine delivery.

Use the SDK rather than writing this yourself — it also does the constant-time
comparison and the tolerance check.

### Verify in Node.js

```typescript theme={null}
import { constructEvent, WebhookSignatureVerificationError } from '@moneyline/sdk';

try {
  // rawBody is a string or Buffer — not a parsed object.
  const event = constructEvent(rawBody, req.headers['x-moneyline-signature'], secret);
  console.log(event.type);
} catch (e) {
  if (e instanceof WebhookSignatureVerificationError) return res.status(401).end();
  throw e;
}
```

`expressMiddleware(secret)` and `fastifyPlugin(secret)` wrap the same check.

### Verify in Python

```python theme={null}
from moneyline.webhook import construct_event, WebhookSignatureVerificationError

try:
    event = construct_event(raw_body, signature_header, secret)
except WebhookSignatureVerificationError:
    return Response(status=401)
```

### Verify in Go

```go theme={null}
err := moneyline.VerifySignature(body, r.Header.Get("X-Moneyline-Signature"), secret, 5*time.Minute)
if err != nil {
    http.Error(w, "invalid signature", http.StatusUnauthorized)
    return
}
```

A runnable receiver for all of this — plus registering, triggering and replaying
deliveries against a local stack — is in
[`examples/webhooks`](https://github.com/paragonhq/moneylined/tree/main/examples/webhooks).

## Retry Logic

Each delivery gets **three attempts**, retried with exponential backoff starting
at 10 seconds:

| Attempt | When |
| - | - |
| 1 | Immediately |
| 2 | \~10 seconds after the first failure |
| 3 | \~20 seconds after the second |

A delivery fails if the endpoint returns a non-2xx status, or if the request
does not complete within a **30 second** timeout. Transport failures — DNS,
refused connections, timeouts — are recorded as attempts just like HTTP errors.

After the third attempt the delivery is marked failed and no further retries
occur. The webhook itself stays active: Moneyline does not disable an endpoint
after repeated failures, so monitor deliveries yourself.

Inspect and re-send from the API:

```bash theme={null}
# Recent delivery attempts, including status and response body
curl http://localhost:3000/v1/webhooks/$WEBHOOK_ID/deliveries \
  -H "Authorization: Bearer $MONEYLINE_API_KEY"

# Re-send one delivery
curl -X POST http://localhost:3000/v1/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/replay \
  -H "Authorization: Bearer $MONEYLINE_API_KEY"
```

## Best Practices

* **Return 2xx quickly** — Process webhook payloads asynchronously
* **Use idempotency** — Events may be delivered more than once; use `event.id` to deduplicate
* **Verify signatures** — Always validate the `X-Moneyline-Signature` header
* **Monitor delivery logs** — Check the dashboard for failed deliveries


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.