FinShare can POST a signed JSON event to your URL whenever something happens on a request: the owner opens the link, approves, a report is ready, access ends. Webhooks are signed with the Standard Webhooks scheme, so any Standard Webhooks library can verify them.
Add an endpoint

Developers › Webhooks.
In the app:
- Go to Developers and click Add endpoint under Webhooks. You need to be an account owner.
- Enter an
httpsURL. - Tick the events you want, or leave them all unticked to receive every event.
- Click Add endpoint and copy the signing secret. It starts with
whsec_and isn't shown again.
Send test posts a signed ping event and shows the HTTP status you returned. Deliveries shows the last 25 deliveries to the endpoint with their status code, error and number of tries.
Over the API:
curl https://app.finshare.io/v1/webhook_endpoints \
-H "Authorization: Bearer fl_live_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://app.example.com/webhooks/finshare", "event_types": ["request.approved", "report.ready", "round.delivered", "grant.revoked", "grant.expired"] }'Returns 201 with { "id", "url", "event_types", "secret" }. GET /webhook_endpoints lists endpoints, DELETE /webhook_endpoints/:id removes one, and POST /webhook_endpoints/:id/ping sends a test.
What a delivery looks like
POST /webhooks/finshare HTTP/1.1
Content-Type: application/json
User-Agent: FinShare-Webhooks/1
webhook-id: evt_Qm7Tz2Kc9Vb4Xn8Rw3Lp
webhook-timestamp: 1793205631
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_Qm7Tz2Kc9Vb4Xn8Rw3Lp",
"type": "report.ready",
"created_at": "2026-10-28T16:40:31.000Z",
"data": {
"request_id": "req_8fK2mQ7xLp3vRt9WnB4c",
"external_ref": "deal-4471",
"round_number": 1,
"report": {
"id": "rpt_Vn4Qx8Lm2Tb6Wk9Rc3Hp",
"report_type": "profit_and_loss",
"title": "Profit and Loss",
"period_start": "2023-01-01",
"period_end": "2025-12-31",
"basis": "accrual",
"company_name": "Ortiz Plumbing LLC",
"status": "ready",
"files": {
"xlsx": "https://app.finshare.io/v1/reports/rpt_Vn4Qx8Lm2Tb6Wk9Rc3Hp/file?format=xlsx",
"csv": "https://app.finshare.io/v1/reports/rpt_Vn4Qx8Lm2Tb6Wk9Rc3Hp/file?format=csv",
"json": "https://app.finshare.io/v1/reports/rpt_Vn4Qx8Lm2Tb6Wk9Rc3Hp/file?format=json"
}
}
}
}Every event body has id, type, created_at and data. data always has request_id and external_ref (your id, or null). The files URLs need your API key; fetch them from your server.
Events
| Event | Sent when | Extra data fields |
|---|---|---|
request.viewed | The owner first opens the review page. | |
request.approved | The owner approves. For a connector, the connection is live. | kind, company_name, access_until, refresh_policy |
request.declined | The owner declines. | reason (may be null) |
request.expired | Reserved. Not currently sent; a consent link that lapses leaves the request awaiting_consent. Check link_expires_at. | |
request.cancelled | You cancel a request before approval. | |
round.awaiting_approval | A refresh or roll forward is waiting for the owner. | round_number, type, approval_url |
round.approved | A round is approved and its pulls are queued: the first pull after approval, an owner-approved update, a standing-approval update, or a connector pull. | round_number, type, and approved_via (owner, standing_grant or connector) on updates and connector pulls |
round.declined | The owner declines an update. | round_number |
round.delivered | Every pull in a round has finished. Check status. | round_number, type, status (delivered, partially_delivered or failed), period_end, reports (the ready ones) |
report.ready | One report tied out and is ready to download. | round_number, report |
report.failed | One report didn't tie out or couldn't be pulled. | round_number, and either report (with status and error) or report_type and error |
grant.revoked | Access was ended early. | revoked_by: owner, requester, system or intuit |
grant.expired | The access window ended. | |
connection.needs_reconnect | QuickBooks stopped accepting the connection. Updates are paused and the owner has been emailed a reconnect link. | |
connection.reconnected | The owner reconnected. |
Under a connector, events about a pull carry the pull's request_id and external_ref, and round.approved also includes connector_request_id. See Connector mode.
Test pings have "type": "ping" and empty data.
Verify the signature
The signature is an HMAC-SHA256 over "<webhook-id>.<webhook-timestamp>.<raw body>", keyed with your secret. The key is the base64-decoded part of the secret after whsec_. The webhook-signature header is v1, followed by the base64 digest, and may hold several space-separated signatures.
Verify against the raw request body, before parsing it as JSON.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyFinShare(secret, headers, rawBody) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigs = headers["webhook-signature"];
if (!id || !ts || !sigs) return false;
// Reject anything more than 5 minutes off, to stop replays.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
"v1," + createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64"),
);
return sigs.split(" ").some((s) => {
const b = Buffer.from(s);
return b.length === expected.length && timingSafeEqual(b, expected);
});
}Delivery and retries
- Return any
2xxwithin 10 seconds to acknowledge. Anything else, including a redirect or a timeout, counts as a failure. - Failed deliveries are retried with exponential backoff, up to 12 attempts over roughly two and a half days.
- Delivery is at least once. Store the
webhook-idand ignore repeats. - Events can arrive out of order. Use
created_at, or fetchGET /requests/:idfor the current state. - Do the work in a background job. Acknowledge first, then download files.
Events are recorded in the same database transaction as the change that caused them, so you never get an event for a change that didn't stick.
A typical handler
- Verify the signature. Reject with
401if it fails. - If you've seen this
webhook-id, return200and stop. - Record the id and enqueue a job. Return
200. - In the job, switch on
type. Forreport.ready, downloaddata.report.files.xlsxwith your API key and attach it to the record matchingexternal_ref.