Skip to content
Justilio

Developers

Connect Justilio in four calls.

A JSON REST API: your back office sends the transfer and the document, receives the pre-analysis through a signed webhook, then records the analyst’s decision. This page explains the integration; the reference details every field.

A test environment, with your own keys and settings, is opened during the pilot.

How a case flows

  1. POST/v1/cases

    Create the case

    The transfer, the customer, the document in base 64 and, if you have them, past transfers. A 202 response as soon as the document is received, checked and stored, encrypted.

  2. WEBHOOKcase.analyzed

    Get notified

    Justilio calls your server as soon as the pre-analysis is published. The event is signed.

  3. GET/v1/cases/{case_id}

    Read the pre-analysis

    Status, findings, values read from the document and, for an incomplete case, the question to ask the customer.

  4. POST/v1/cases/{case_id}/decision

    Record the decision

    Validated, refused, document requested or reopened. The decision feeds the history used for later cases.

Two calls complete the flow: POST /v1/cases/{case_id}/documents adds a document, for example after the customer’s answer, and POST /v1/cases/{case_id}/reanalyze runs the analysis again, for example after a register was unavailable.

Authentication and idempotency

Each institution gets its own API keys. Send the key in the Authorization header; it is shown only once, and Justilio keeps only a fingerprint of it. Call the API from your servers only.

Every write carries a unique Idempotency-Key header, for example a UUID. Sent again with the same request, it returns the original response and creates nothing; with another request, it is refused. transfer_ref adds a second guarantee: one case per transfer.

X-Request-Id, optional, is kept in the access log to trace a request.

Headers
Authorization: Bearer jst_<prefix>_<secret>
Idempotency-Key: 7b0e2c64-6f0a-4d6b-9a1e-3c5d8f2e4a10
X-Request-Id: req_20261106_0042

Create a case

Send the transfer, the customer, the document encoded in base 64 and, if you have them, the customer’s past transfers: they reveal a supplier paid on a new account, or an invoice already paid. The 202 response comes as soon as the document is received, checked and stored, encrypted. A document refused by the security checks returns a 422, and nothing is stored.

Request
curl "$JUSTILIO_API_URL/v1/cases" \
  -H "Authorization: Bearer $JUSTILIO_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data @case.json
Body (case.json), document truncated
{
  "transfer_ref": "trf_20261106_000481",
  "transfer": {
    "amount": "48000.00",
    "currency": "EUR",
    "execution_date": "2026-11-06",
    "direction": "outgoing",
    "counterparty_name": "Velmora Conseil",
    "counterparty_iban": "FR7699999000010000048006191",
    "reference": "FACT VC-2026-014",
    "declared_purpose": "Mission de conseil stratégique",
    "vop_result": "match"
  },
  "client": {
    "ref": "cli_8f3a2",
    "kind": "company",
    "name": "Nordis Trading",
    "siren": "093847218"
  },
  "prior_transfers": [
    {
      "amount": "1250.00",
      "currency": "EUR",
      "execution_date": "2026-10-02",
      "direction": "outgoing",
      "counterparty_name": "Brévalis Logistique",
      "counterparty_iban": "FR7699999000010000027154877",
      "reference": "FACT 2026-0917"
    }
  ],
  "document": {
    "filename": "facture-VC-2026-014.pdf",
    "content_base64": "JVBERi0xLjcK"
  }
}
Response 202
{
  "case_id": "4f6c2a0e-8b1d-4c3e-9a57-2d8e1b6f0c41",
  "state": "received",
  "revision": 1
}

Receive the signed webhook

As soon as a pre-analysis is published, Justilio sends a POST to the HTTPS address you registered. The body only carries identifiers and the status: then read the case with GET /v1/cases/{case_id}.

Verify the signature before trusting the event:

  1. read the raw body, before any JSON parsing;
  2. compute the HMAC-SHA256 of the timestamp t, a dot and the body, with your webhook secret, then compare it with v1 in constant time;
  3. refuse a signature older than five minutes, and deduplicate on Justilio-Event-Id: the same event may arrive twice.

Answer quickly with a 2xx status. Otherwise (another status, a timeout, or a redirect, never followed), Justilio retries after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, within 24 hours. The case remains readable afterwards.

Request received
POST /your/webhook/endpoint
Content-Type: application/json
Justilio-Event-Id: 6a0d9e44-2c71-4b5f-a8d3-91e7f0b25c6a
Justilio-Signature: t=1794042861, v1=0a9deb684ee9bc9f15d34dd851b36f25812e4afb6ca0e24c3001c089a97030c9
User-Agent: Justilio-Webhooks/1

{
  "id": "6a0d9e44-2c71-4b5f-a8d3-91e7f0b25c6a",
  "type": "case.analyzed",
  "created_at": "2026-11-06T09:14:21Z",
  "data": {
    "case_id": "4f6c2a0e-8b1d-4c3e-9a57-2d8e1b6f0c41",
    "run_id": "b2d7e3f1-5a64-4e8b-8c19-0f3a9d2c7e58",
    "status": "suspect",
    "rules_version": "examplebank-2026-11-01"
  }
}
Verify the signature in Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received (a Buffer), before any JSON parsing.
export function verifyJustilio(rawBody, header, secret, toleranceSeconds = 300) {
  const match = /^t=(\d{1,12}), v1=([0-9a-f]{64})$/.exec(header ?? "");
  if (!match) return false;
  const timestamp = Number(match[1]);
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(match[2], "hex"));
}
In Python
import hashlib
import hmac
import re
import time


def verify_justilio(raw_body: bytes, header: str, secret: bytes, tolerance: int = 300) -> bool:
    """True if `Justilio-Signature` signs this exact body, less than 5 minutes ago."""
    match = re.fullmatch(r"t=(\d{1,12}), v1=([0-9a-f]{64})", header or "")
    if match is None:
        return False
    timestamp = int(match.group(1))
    if abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, match.group(2))
Test vector
secret:    whsec_example_not_a_real_secret
header:    t=1794042861, v1=0a9deb684ee9bc9f15d34dd851b36f25812e4afb6ca0e24c3001c089a97030c9
body:      {"id":"6a0d9e44-2c71-4b5f-a8d3-91e7f0b25c6a","type":"case.analyzed","created_at":"2026-11-06T09:14:21Z","data":{"case_id":"4f6c2a0e-8b1d-4c3e-9a57-2d8e1b6f0c41","run_id":"b2d7e3f1-5a64-4e8b-8c19-0f3a9d2c7e58","status":"suspect","rules_version":"examplebank-2026-11-01"}}

With this secret, this exact body and this header, verification succeeds with the clock frozen at timestamp t.

Read the pre-analysis

The response gives the status, the findings with the value read (found) and the value expected (expected), the values read from the document, the forgery check and, for an incomplete case only, the question to ask the customer. analysis stays null until the first analysis is published.

Here is the suspicious case from the home page, as the API returns it.

Response 200
{
  "case_id": "4f6c2a0e-8b1d-4c3e-9a57-2d8e1b6f0c41",
  "transfer_ref": "trf_20261106_000481",
  "state": "analyzed",
  "revision": 1,
  "created_at": "2026-11-06T09:14:02Z",
  "analysis": {
    "analysis_id": "b2d7e3f1-5a64-4e8b-8c19-0f3a9d2c7e58",
    "status": "suspect",
    "status_label": "suspect",
    "forgery_check": "modified",
    "forgery_label": "falsification détectée",
    "findings": [
      {
        "code": "FORGERY_COVERED_TEXT",
        "effect": "suspect",
        "effect_label": "suspect",
        "message": "Sous le total de 48 000,00 €, le PDF garde un ancien total de 4 800,00 €, masqué par un rectangle blanc.",
        "document": "facture-VC-2026-014.pdf",
        "field": "total_ttc",
        "found": "4800.00",
        "expected": null
      },
      {
        "code": "COMMON_OFFICER",
        "effect": "vigilance",
        "effect_label": "vigilance",
        "message": "Nordis Trading et Velmora Conseil ont un dirigeant en commun.",
        "document": null,
        "field": null,
        "found": null,
        "expected": null
      },
      {
        "code": "COMPANY_YOUNG",
        "effect": "vigilance",
        "effect_label": "vigilance",
        "message": "Velmora Conseil a été créée le 17/09/2026, sept semaines avant le virement.",
        "document": "facture-VC-2026-014.pdf",
        "field": "issuer_siren",
        "found": "2026-09-17",
        "expected": null
      },
      {
        "code": "LARGE_AMOUNT_SMALL_SUPPLIER",
        "effect": "vigilance",
        "effect_label": "vigilance",
        "message": "48 000 € pour une société sans salarié, créée il y a moins d'un an.",
        "document": "facture-VC-2026-014.pdf",
        "field": "total_ttc",
        "found": "48000.00",
        "expected": null
      },
      {
        "code": "NEW_COUNTERPARTY",
        "effect": "vigilance",
        "effect_label": "vigilance",
        "message": "Premier virement de ce client vers Velmora Conseil, d'après l'historique transmis.",
        "document": null,
        "field": null,
        "found": null,
        "expected": null
      }
    ],
    "document_kind": "invoice",
    "extracted": {
      "issuer_name": "Velmora Conseil",
      "issuer_siren": "048273619",
      "issuer_vat_number": "FR63048273619",
      "recipient_name": "Nordis Trading",
      "invoice_number": "VC-2026-014",
      "invoice_date": "2026-11-03",
      "total_ht": "40000.00",
      "total_tva": "8000.00",
      "total_ttc": "48000.00",
      "currency": "EUR",
      "iban": "FR7699999000010000048006191"
    },
    "client_question": null,
    "rules_version": "examplebank-2026-11-01",
    "analyzed_at": "2026-11-06T09:14:21Z"
  }
}

Statuses and effects

CodeLabelMeaning
consistentcohérentEverything matches.
incompleteincompletInformation is missing: client_question gives the question to ask.
inconsistentincohérentThe document does not match the transfer.
suspectsuspectSign of fraud or serious risk. Never a question to the customer.

Each finding carries an effect: suspect, inconsistent, incomplete, vigilance (a point to watch, which alone does not change the status) or positive. The status is the most serious effect found. No score or point in the responses: findings explain the status.

Record the decision

The analyst decides on the analysis they read (analysis_id): validated, refused, document_requested or reopened. The decision joins the history: it is what lets Justilio recognise, for example, an invoice already paid. A decision on an outdated analysis is refused with 409 stale_analysis; during a new analysis, with 409 analysis_running. The response then gives published_analysis_id.

Body
{
  "analysis_id": "b2d7e3f1-5a64-4e8b-8c19-0f3a9d2c7e58",
  "decision": "refused",
  "analyst_ref": "analyst_042",
  "comment": "Facture retouchée entre sociétés liées : transmis à la conformité."
}
Response 200
{
  "decision_id": "e81a4c27-3f9b-4d06-b5e2-7c40a1d9f363"
}

Errors

An error returns a stable code and a message in French: rely on the code. 429 and 503 responses carry a Retry-After header.

HTTPCodeWhen
400idempotency_key_requiredMissing or too long Idempotency-Key header.
401unauthorizedMissing, invalid or revoked API key.
404case_not_foundNo case of your institution has this identifier.
409transfer_already_receivedThis transfer already has its case: the response gives its case_id.
409stale_analysisThe decision concerns an analysis that is no longer the latest published.
409analysis_runningA new analysis is running: decide on its result.
409request_in_progressThe same request is still running.
409case_closedThe case is being purged or has been purged.
413payload_too_largeBody too large: a 10 MB document at most.
422invalid_requestInvalid request: details points to the fields at fault.
422document_rejectedDocument refused by the security checks, or badly encoded (invalid_document). Nothing is stored.
422idempotency_key_reusedIdempotency key already used for another request.
429too_many_receptionsToo many documents being received: retry after Retry-After.
503reception_unavailableTemporarily unavailable (or deposit_expired): retry, and send the document again if needed.

Limits, data and security

  • One document per request, 10 MB at most, in PDF, JPEG, PNG or HEIC; your settings may narrow these formats.
  • Up to 500 past transfers per case.
  • Documents encrypted per institution, hosted in Europe, kept for the period you set.
  • A social security number read on a document is never stored.
  • API keys are kept as fingerprints, and each institution only sees its own cases.

Let’s prepare the integration with your technical team.

For the pilot, we open a test environment with your own keys and settings.