Guide to Laso Finance Webhooks: Features and Best Practices
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:
{
"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:
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:
{
"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:
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 gives it a public URL to register:
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:
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:
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 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, 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-webhookon a schedule and alert ondisabledor a risingconsecutive_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 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 fills the biggest gap by storing and retrying every notification for your agent. Create a free account and register a Hookdeck source URL with Laso.