# Guide to Laso Finance Webhooks: Features and Best Practices

[Laso Finance](https://laso.finance) gives AI agents money to work with: bank accounts, cards, payouts and an agent wallet, all driven through an API. The account owner gets notifications as things happen, a transfer landing or a card order shipping, and Laso's webhooks pass those same notifications to the agent. That lets an agent react to a completed payout or a new deposit instead of polling `listBankingTransactions` or `/get-withdrawal-status` on a timer.

Below: how an agent registers its single webhook, what the notification payload holds, how to verify it, and why Laso's no-retry delivery needs a plan for missed events.

## What are Laso Finance webhooks?

Each Laso account can have one webhook URL, registered through the API by the agent. Every notification the account owner receives through push, SMS, Telegram or the app is also POSTed to that URL, signed per Standard Webhooks with an account-level `whsec_` secret. There is nothing to choose: one webhook, every notification.

The payload mirrors the notification the owner sees. It has a type, a title and a line of text, not the transaction or withdrawal object, so an agent treats it as a prompt to check the relevant status endpoint. If the agent's runtime can't receive inbound HTTP, Laso suggests pointing the URL at whatever your harness offers for external events (a gateway endpoint, a relay or a queue you drain), or carrying on polling.

## Laso Finance webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: `POST /register-webhook`, `GET /get-webhook`, `POST /delete-webhook` |
| Webhooks | One per account. Public HTTPS URL |
| Subscription model | Every notification. No selection |
| Signature | Standard Webhooks HMAC-SHA256 |
| Headers | `webhook-id` (`msg_...`), `webhook-timestamp`, `webhook-signature` |
| Secret | `whsec_...`, returned only by `POST /register-webhook` |
| Rotation | Re-registering rotates the secret. No grace period documented |
| Body | `{type, timestamp, data}` |
| Event routing | Body field `type` (`notification.<category>`) |
| Timeout | 10 seconds |
| Retries | None |
| Disabling | After 50 consecutive failures. Re-registering re-enables |
| Test delivery | A signed `notification.account` on registration |
| Source IPs | Not published |

## Events and payloads

| Field | Meaning |
| --- | --- |
| `type` | The event type: `notification.` plus the category. This is what handlers switch on |
| `timestamp` | ISO 8601 time of the notification |
| `data.user_id` | The account owner |
| `data.title` | Notification title, as shown to the owner |
| `data.text` | Notification text, as shown to the owner |
| `data.category` | The bare category, repeating the suffix of `type` |

Laso's documented example:

```json
{
  "type": "notification.transaction",
  "timestamp": "2026-07-31T18:04:05.000Z",
  "data": {
    "user_id": "usr_abc123",
    "title": "Bank transfer complete",
    "text": "$250.00 landed in your agent wallet.",
    "category": "transaction"
  }
}

```

Laso says categories "include" these eight, so expect more:

| Event | Triggered When |
| --- | --- |
| `notification.account` | Banking application or account status changes. Also the test delivery sent when you register |
| `notification.transaction` | A bank transfer or payout completes |
| `notification.deposit` | A deposit lands in the agent wallet |
| `notification.withdrawal` | A withdrawal changes state |
| `notification.card` | A card order is updated |
| `notification.giftCard` | A gift card order is updated |
| `notification.refund` | Named in the docs, not described |
| `notification.balanceUpdate` | Named in the docs, not described |

Only `notification.transaction` has an example. The other descriptions follow from the category names and the kinds of notification Laso lists. There's no event ID in the body and no ID for the underlying transaction either, so don't try to parse amounts out of `text`; call the status endpoints instead.

## Setting up Laso Finance webhooks

Register the URL with a Bearer token from `/auth`:

```bash
curl -X POST https://laso.finance/register-webhook \
  -H "Authorization: Bearer $ID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://agent.example.com/webhooks/laso-finance"}'

```

The response is the only place the secret ever appears:

```json
{
  "registered": true,
  "url": "https://agent.example.com/webhooks/laso-finance",
  "secret": "whsec_EXAMPLEONLYnotarealsecretAAAAAAA",
  "signing": "standard-webhooks"
}

```

Store `secret` as `LASO_WEBHOOK_SECRET` immediately. Registering also sends a signed `notification.account` test delivery to the URL, which only needs a `2xx`, and tells the account owner through their other channels that an agent registered a webhook.

Check the registration at any time:

```bash
curl https://laso.finance/get-webhook -H "Authorization: Bearer $ID_TOKEN"

```

It returns the URL, whether it is `disabled`, `consecutive_failures`, and the status, time and detail of the last attempt. `POST /delete-webhook` removes it; the owner's own notifications carry on.

If you develop the agent locally, the [Hookdeck CLI](/docs/cli) gives it a public URL to register:

```bash
npx hookdeck-cli listen 3000 laso-finance --path /webhooks/laso-finance

```

**With Hookdeck.** Register the URL of a Hookdeck **Laso Finance** source and paste the returned `whsec_` secret into the source. The source accepts only `POST`. Laso always gets a `200` from Hookdeck, which keeps the failure counter at zero; a delivery with a bad signature is recorded as failed verification and held back from your agent.

## Securing Laso Finance webhooks

Laso's construction is standard: HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}` with the base64-decoded secret after `whsec_` as the key, sent as `v1,<base64>`. Laso's sample compares the whole header against one value; the verifier below splits the header on spaces and accepts any `v1` match, which also works for a single signature:

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

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Since there are no retries, the handler should do as little as possible before acknowledging:

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

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/laso-finance', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.LASO_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const notification = parseJsonObject(req.body);
  if (!notification) return res.sendStatus(400); // signed, but not a JSON object
  if (!notification.data || typeof notification.data !== 'object') return res.sendStatus(400);
  // Wake the agent; it reads the real state from the status endpoints.
  try {
    await agentInbox.push({
      messageId: req.headers['webhook-id'],
      category: notification.data.category,
      title: notification.data.title,
    });
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

```

`agentInbox` stands for whatever your agent harness uses to receive external events.

Laso asks receivers to reject timestamps "more than a few minutes old" but gives no figure. Standard Webhooks libraries use 5 minutes. Hookdeck has no timestamp tolerance and keeps Laso's original `webhook-timestamp` on retries and replays, so a short window behind Hookdeck would reject the very redeliveries that make up for Laso's lack of retries. Check the signature only, as above, and deduplicate on `webhook-id`.

> **Make Laso Finance webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) gives Laso's one-shot deliveries the retries they lack: it verifies each notification, stores it, and keeps trying your agent until it answers.

## Laso Finance webhook limitations and pain points

### No retries

**The Problem:** If your agent is down or slow for one delivery, that notification is gone.

**Why It Happens:** Laso times out after 10 seconds and never retries. A missed delivery only counts towards the failure total.

**Workarounds:**

* Acknowledge within a few hundred milliseconds and do everything else afterwards.
* Keep polling the status endpoints as a backstop, at a slower interval.

**How Hookdeck Can Help:** Hookdeck accepts each notification and then delivers it to your agent with [retries](/docs/retries), so a restart or deploy no longer loses events.

### Auto-disable after 50 failures

**The Problem:** Notifications stop arriving and nothing tells the agent.

**Why It Happens:** After 50 consecutive failures, Laso disables the registration. Re-registering re-enables it, but also rotates the secret.

**Workarounds:**

* Check `GET /get-webhook` on a schedule and alert on `disabled` or a rising `consecutive_failures`.
* When you re-register, update the stored secret in the same step.

**How Hookdeck Can Help:** Hookdeck answers Laso with `200`, so the failure counter doesn't climb while your agent is unavailable.

### Rotation by re-registration

**The Problem:** Deliveries fail verification right after someone re-registers.

**Why It Happens:** Each registration issues a new secret, and Laso describes no overlap for the old one.

**Workarounds:**

* Only re-register from code that saves the new secret atomically.

**How Hookdeck Can Help:** With Hookdeck's URL registered, the secret change is one update on the source.

### Notifications, not records

**The Problem:** The agent can't tell which transfer or card order a notification refers to.

**Why It Happens:** `data` holds the notification's title and text, with no object IDs.

**Workarounds:**

* Use each notification as a trigger to fetch recent transactions or withdrawals and reconcile.

**How Hookdeck Can Help:** Hookdeck's [filters](/docs/filters) can send each category to a different handler, so only the relevant status check runs.

## Best practices

### Verify before trusting money movements

Read the raw body with `express.raw({ type: '*/*' })`, `await request.text()` in a Next.js handler, or FastAPI's `await request.body()`, and check the signature before the agent acts on anything.

### Don't act on `text`

The text is for humans. Fetch the transaction or withdrawal state from Laso's API before an agent moves money in response.

### Expect new categories

Return `2xx` for unfamiliar `notification.*` types so they never count as failures.

### Remember the owner is told

Registering a webhook notifies the account owner. Make sure owners expect it, especially in multi-tenant agent products.

## Conclusion

Laso Finance's webhooks are a direct line from an agent's bank account to the agent, signed with a standard scheme. They are also unforgiving: one URL, one secret that only appears at registration, a 10-second timeout, no retries and auto-disable after 50 failures. Answer instantly, verify, fetch real state from the API, and monitor the registration.

[Hookdeck Event Gateway](https://hookdeck.com) fills the biggest gap by storing and retrying every notification for your agent. [Create a free account](https://dashboard.hookdeck.com/signup) and register a Hookdeck source URL with Laso.