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.
How a case flows
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.
WEBHOOKcase.analyzed
Get notified
Justilio calls your server as soon as the pre-analysis is published. The event is signed.
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.
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.
Authorization: Bearer jst_<prefix>_<secret>
Idempotency-Key: 7b0e2c64-6f0a-4d6b-9a1e-3c5d8f2e4a10
X-Request-Id: req_20261106_0042Create 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.
curl "$JUSTILIO_API_URL/v1/cases" \
-H "Authorization: Bearer $JUSTILIO_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @case.json{
"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"
}
}{
"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:
- read the raw body, before any JSON parsing;
- compute the HMAC-SHA256 of the timestamp
t, a dot and the body, with your webhook secret, then compare it withv1in constant time; - 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.
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"
}
}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"));
}
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))
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"}}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.
{
"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
| Code | Label | Meaning |
|---|---|---|
consistent | cohérent | Everything matches. |
incomplete | incomplet | Information is missing: client_question gives the question to ask. |
inconsistent | incohérent | The document does not match the transfer. |
suspect | suspect | Sign 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.
{
"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é."
}{
"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.
| HTTP | Code | When |
|---|---|---|
| 400 | idempotency_key_required | Missing or too long Idempotency-Key header. |
| 401 | unauthorized | Missing, invalid or revoked API key. |
| 404 | case_not_found | No case of your institution has this identifier. |
| 409 | transfer_already_received | This transfer already has its case: the response gives its case_id. |
| 409 | stale_analysis | The decision concerns an analysis that is no longer the latest published. |
| 409 | analysis_running | A new analysis is running: decide on its result. |
| 409 | request_in_progress | The same request is still running. |
| 409 | case_closed | The case is being purged or has been purged. |
| 413 | payload_too_large | Body too large: a 10 MB document at most. |
| 422 | invalid_request | Invalid request: details points to the fields at fault. |
| 422 | document_rejected | Document refused by the security checks, or badly encoded (invalid_document). Nothing is stored. |
| 422 | idempotency_key_reused | Idempotency key already used for another request. |
| 429 | too_many_receptions | Too many documents being received: retry after Retry-After. |
| 503 | reception_unavailable | Temporarily 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.