Appearance
Events and webhooks
Finto tells you about everything that concerns you with events: a new application, a new version, the client choosing your offer, the application closing, the client withdrawing consent. You receive them in two ways, and the same event is available through both:
| Way | How | When to use |
|---|---|---|
| Webhook | Finto sends an HTTPS POST to your endpoint within seconds | Recommended: you react at once |
| Events feed | You call GET /v1/events?after=<sequence> | Always available. Catch up after downtime, or integrate without a public endpoint |
Use both: webhooks for speed, the feed every few minutes as a safety net. If your network does not allow inbound connections, the feed alone is a complete integration: poll it every 30 to 60 seconds.
The event object
json
{
"id": "cmi7evt0041lb08x0y1z2w3",
"sequence": 41,
"type": "application.submitted",
"environment": "live",
"createdAt": "2026-11-20T08:14:06Z",
"data": {
"applicationId": "cmi7q2x4k0001lb08h3k2v9de",
"reference": "P-7KQ2MX",
"version": 1,
"changes": null,
"closedReason": null,
"decisionId": null
}
}Events never contain personal data. They carry identifiers only; you fetch the application with your API key. A leaked webhook log or queue therefore exposes nothing about the client.
type | When | data fields set | What you do |
|---|---|---|---|
application.submitted | An application was sent to you | applicationId, reference, version | Fetch the application and review it |
application.updated | A new version for you: Finto corrected data at the client's request, or documents were added for you | same, plus changes (data, documents) | Fetch it again; decide again if needed |
offer.selected | The client chose your offer | same, plus decisionId | Fetch it, then continue with the agreement |
application.closed | The application ended for you | same, plus closedReason | Stop the review; only outcome reports are accepted now |
application.withdrawn | The client withdrew consent | applicationId, reference, version | Stop, see lifecycle.md |
ping | You asked for a test event (POST /v1/events/test) | none | Nothing; check that it arrived and verified |
New event types may be added at any time. Ignore types you do not know and answer 2xx as usual. New types are announced in the changelog at least 30 days before they are sent.
Webhook delivery
The request
http
POST /finto/webhooks HTTP/1.1
Host: api.your-bank.example
Content-Type: application/json
User-Agent: Finto-Webhooks/1
Finto-Event-Id: cmi7evt0041lb08x0y1z2w3
Finto-Event-Type: application.submitted
Finto-Signature: t=1795162446,v1=841f6842d137de4ec79ab73264ff27d36991cf720baeb49a79d54ca9dbaa17d2
{"id":"cmi7evt0041lb08x0y1z2w3","sequence":41,...}Finto-Event-Id and Finto-Event-Type are there for your logs and routing. They are not signed: once the signature is verified, rely on the body.
Your response
- Answer any
2xxstatus within 10 seconds (the whole exchange, not the first byte). The body is ignored. - Answer first, process afterwards: put the event on a queue, return
200, then fetch the application in a worker. - Anything else counts as a failure: other status codes, timeouts, TLS errors. Redirects are not followed.
Retries
A failed delivery is retried after 1, 5, 15 and 30 minutes, then after 1, 2, 4, 8, 12 and 24 hours (about two days in total). Every attempt is signed again with a fresh t, so a retry two days later still passes your 300-second check. After the last attempt Finto stops retrying, its staff is alerted, and the event remains in the feed for 30 days. A ping is not retried.
Duplicates and order
- The same event may arrive more than once (a retry after a lost
200, or once by webhook and once from the feed). Ignore anidyou already processed. - Webhooks may arrive out of order. Do not build state from the events themselves: treat each one as "something changed" and fetch the current application. Its
version,stateanddecisionare always current. - The feed, unlike webhooks, is always in order (see below).
Your endpoint
- HTTPS on port 443, with a certificate from a public CA, on a public host name. No user name or password in the URL, no other port. Finto resolves the host on every delivery and does not send to private or internal addresses.
- Send the URL to Finto; Finto configures it and gives you the webhook secret (
whsec_...) through a one-time link to your technical contact.GET /v1/meshows the URL Finto uses. - Finto sends webhooks from fixed IP addresses, listed in
webhookSourceIpsofGET /v1/meand in your onboarding pack. Allow only those in your firewall, and still verify the signature. A change of these addresses is announced at least 30 days ahead.
Verifying the signature
Every webhook carries a Finto-Signature header:
text
Finto-Signature: t=1795162446,v1=841f6842d137de4ec79ab73264ff27d36991cf720baeb49a79d54ca9dbaa17d2tis the time of signing, in Unix seconds (digits only).v1is the HMAC-SHA256 of the signed payload, as lowercase hex.- During a secret rotation Finto sends two
v1values, one per secret. Accept the request if any of them matches.
To verify:
- Take the raw request body exactly as received, before any JSON parsing. Parsing and serialising again changes the bytes and breaks the signature.
- Build the signed payload: the value of
t, a dot (.), then the raw body. - Compute HMAC-SHA256 of the payload. The key is your whole webhook secret, including the
whsec_prefix, as UTF-8 bytes. - Compare your result with each
v1in constant time. - Reject the request if
tdiffers from your clock by more than 300 seconds (protection against replayed requests). Keep your clock in sync with NTP.
If verification fails, answer 400 and do not process the body.
Test vector
Check your implementation against this before connecting to the sandbox:
| Secret | whsec_5fWz1kXq8rN3tY7uB2mC9vD4hJ6gL0pS |
t | 1795162446 |
| Raw body | below, one line, no trailing newline |
| Expected header | t=1795162446,v1=841f6842d137de4ec79ab73264ff27d36991cf720baeb49a79d54ca9dbaa17d2 |
json
{"id":"cmi7evt0041lb08x0y1z2w3","sequence":41,"type":"application.submitted","environment":"sandbox","createdAt":"2026-11-20T08:14:06Z","data":{"applicationId":"cmi7q2x4k0001lb08h3k2v9de","reference":"P-7KQ2MX","version":1,"changes":null,"closedReason":null,"decisionId":null}}The timestamp is fixed, so pass 1795162446 as the current time (nowSeconds in the functions below) when you test with the vector.
Node.js (18+)
js
import { createHmac, timingSafeEqual } from "node:crypto";
/** rawBody: Buffer with the exact request body. */
export function verifyFintoSignature(header, rawBody, secret, toleranceSeconds = 300, nowSeconds = Date.now() / 1000) {
let t = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t" && /^\d{1,10}$/.test(value ?? "")) t = value;
else if (key === "v1" && value) signatures.push(value);
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(nowSeconds - Number(t)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${t}.`, "ascii"), rawBody]))
.digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Express: read the raw body for this route only.
// app.post("/finto/webhooks", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyFintoSignature(req.get("Finto-Signature") ?? "", req.body, process.env.FINTO_WEBHOOK_SECRET)) {
// return res.sendStatus(400);
// }
// queue.push(JSON.parse(req.body.toString("utf8")));
// res.sendStatus(200);
// });Python (3.8+)
python
import hashlib
import hmac
import re
import time
def verify_finto_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300, now_seconds: float = None) -> bool:
t, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and re.fullmatch(r"\d{1,10}", value):
t = value
elif key == "v1" and value:
signatures.append(value)
if t is None or not signatures:
return False
now = time.time() if now_seconds is None else now_seconds
if abs(now - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode("utf-8"), t.encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
# Flask: verify_finto_signature(request.headers.get("Finto-Signature", ""), request.get_data(), secret)
# Django: verify_finto_signature(request.headers.get("Finto-Signature", ""), request.body, secret)Java (17+)
java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class FintoSignature {
/** rawBody: the exact request body bytes. nowSeconds: System.currentTimeMillis() / 1000. */
public static boolean verify(String header, byte[] rawBody, String secret, long toleranceSeconds, long nowSeconds)
throws Exception {
String t = null;
List<String> signatures = new ArrayList<>();
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t") && kv[1].matches("\\d{1,10}")) t = kv[1];
else if (kv[0].equals("v1") && !kv[1].isEmpty()) signatures.add(kv[1]);
}
if (t == null || signatures.isEmpty()) return false;
if (Math.abs(nowSeconds - Long.parseLong(t)) > toleranceSeconds) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.US_ASCII));
byte[] expected = mac.doFinal(rawBody);
for (String signature : signatures) {
byte[] given;
try {
given = HexFormat.of().parseHex(signature);
} catch (IllegalArgumentException e) {
continue;
}
if (MessageDigest.isEqual(expected, given)) return true;
}
return false;
}
}In Spring, take the body as @RequestBody byte[] body (not a mapped object) and the header with @RequestHeader("Finto-Signature").
C# (.NET 6+)
csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
public static class FintoSignature
{
/// <param name="rawBody">The exact request body bytes.</param>
/// <param name="nowSeconds">DateTimeOffset.UtcNow.ToUnixTimeSeconds().</param>
public static bool Verify(string header, byte[] rawBody, string secret, long nowSeconds, int toleranceSeconds = 300)
{
string? t = null;
var signatures = new List<string>();
foreach (var part in header.Split(','))
{
var kv = part.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t" && Regex.IsMatch(kv[1], "^[0-9]{1,10}$")) t = kv[1];
else if (kv[0] == "v1" && kv[1].Length > 0) signatures.Add(kv[1]);
}
if (t is null || signatures.Count == 0) return false;
if (Math.Abs(nowSeconds - long.Parse(t)) > toleranceSeconds) return false;
var payload = Encoding.ASCII.GetBytes(t + ".").Concat(rawBody).ToArray();
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), payload);
foreach (var signature in signatures)
{
byte[] given;
try { given = Convert.FromHexString(signature); }
catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(expected, given)) return true;
}
return false;
}
}In ASP.NET Core, read the raw body before any model binding, asynchronously (Kestrel forbids synchronous reads):
csharp
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms);
var ok = FintoSignature.Verify(Request.Headers["Finto-Signature"].ToString(), ms.ToArray(), secret,
DateTimeOffset.UtcNow.ToUnixTimeSeconds());PHP (7.4+)
php
function verifyFintoSignature(string $header, string $rawBody, string $secret, int $tolerance = 300, ?int $nowSeconds = null): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) {
continue;
}
if ($kv[0] === 't' && preg_match('/^\d{1,10}$/', $kv[1])) {
$t = $kv[1];
} elseif ($kv[0] === 'v1' && $kv[1] !== '') {
$signatures[] = $kv[1];
}
}
if ($t === null || !$signatures) {
return false;
}
$now = $nowSeconds ?? time();
if (abs($now - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_FINTO_SIGNATURE'] ?? '';Common mistakes
| Symptom | Cause |
|---|---|
| Every signature fails | The body was parsed and serialised again, or decoded with the wrong charset. Use the raw bytes |
| Every signature fails | The whsec_ prefix was cut off the key |
| Fails only sometimes | Your clock drifts: check NTP. Or you compare against only the first v1 during a rotation |
| Fails after a rotation | The new secret is not deployed yet. Both signatures are sent for at least 24 hours |
The events feed
http
GET /v1/events?after=40&limit=100
Authorization: Bearer fnt_live_...json
{
"data": [{ "id": "cmi7evt0041lb08x0y1z2w3", "sequence": 41, "type": "application.submitted", "...": "..." }],
"hasMore": false,
"lastSequence": 41
}sequenceis yours alone and has no gaps: your first event is 1, the next 2, and so on. The feed returns events in that order, and once you have seen an event, no event with a lowersequenceappears later. A gap in what you processed therefore always means something was skipped on your side.- Process the page, then store
lastSequence. If you crash in between, you read the page again; duplicates are filtered byid. - While
hasMoreistrue, call again right away with the new cursor. - Without
after, the feed starts from the oldest event still kept. - Events are kept for 30 days. If events after your cursor were already deleted (you were away longer), the response is
410 cursor_expired: reconcile withGET /v1/applications?updatedAfter=..., then continue withoutafterand store the newlastSequence. - Webhooks and the feed deliver the same events with the same
id.
A complete polling loop:
js
let after = await loadCursor(); // null the first time
for (;;) {
const query = after === null ? "" : `after=${after}&`;
const page = await finto("GET", `/v1/events?${query}limit=100`);
for (const event of page.data) await handleEvent(event); // skips known ids
after = page.lastSequence;
await saveCursor(after);
if (!page.hasMore) await sleep(30_000);
}Recommended processing
If processing one event keeps failing because of a bug on your side, park it (a dead-letter queue) after a few attempts and move on, so that one event cannot hold up the rest of the feed.
The reference lender in examples/node/lender.mjs implements exactly this, in one file without dependencies.