Skip to content

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:

WayHowWhen to use
WebhookFinto sends an HTTPS POST to your endpoint within secondsRecommended: you react at once
Events feedYou 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.

typeWhendata fields setWhat you do
application.submittedAn application was sent to youapplicationId, reference, versionFetch the application and review it
application.updatedA new version for you: Finto corrected data at the client's request, or documents were added for yousame, plus changes (data, documents)Fetch it again; decide again if needed
offer.selectedThe client chose your offersame, plus decisionIdFetch it, then continue with the agreement
application.closedThe application ended for yousame, plus closedReasonStop the review; only outcome reports are accepted now
application.withdrawnThe client withdrew consentapplicationId, reference, versionStop, see lifecycle.md
pingYou asked for a test event (POST /v1/events/test)noneNothing; 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 2xx status 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 an id you 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, state and decision are 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/me shows the URL Finto uses.
  • Finto sends webhooks from fixed IP addresses, listed in webhookSourceIps of GET /v1/me and 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=841f6842d137de4ec79ab73264ff27d36991cf720baeb49a79d54ca9dbaa17d2
  • t is the time of signing, in Unix seconds (digits only).
  • v1 is the HMAC-SHA256 of the signed payload, as lowercase hex.
  • During a secret rotation Finto sends two v1 values, one per secret. Accept the request if any of them matches.

To verify:

  1. Take the raw request body exactly as received, before any JSON parsing. Parsing and serialising again changes the bytes and breaks the signature.
  2. Build the signed payload: the value of t, a dot (.), then the raw body.
  3. Compute HMAC-SHA256 of the payload. The key is your whole webhook secret, including the whsec_ prefix, as UTF-8 bytes.
  4. Compare your result with each v1 in constant time.
  5. Reject the request if t differs 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:

Secretwhsec_5fWz1kXq8rN3tY7uB2mC9vD4hJ6gL0pS
t1795162446
Raw bodybelow, one line, no trailing newline
Expected headert=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 ​

SymptomCause
Every signature failsThe body was parsed and serialised again, or decoded with the wrong charset. Use the raw bytes
Every signature failsThe whsec_ prefix was cut off the key
Fails only sometimesYour clock drifts: check NTP. Or you compare against only the first v1 during a rotation
Fails after a rotationThe 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
}
  • sequence is 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 lower sequence appears 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 by id.
  • While hasMore is true, 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 with GET /v1/applications?updatedAfter=..., then continue without after and store the new lastSequence.
  • 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);
}

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.