Callback URL (Webhooks)
If you provided a callback URL during flow creation, this URL is called for every update to the flow. The method will be POST, and the request to your URL will look as follows:
Configuration
Set the webhook URL when creating a flow:
{
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
"callbackUrl": "https://your.callback/url",
"requestedChecks": [...]
}
Webhook Payload
POST https://your.callback/url
Content-Type: application/json
{
"checkToken": "1234ab",
"event": "UPDATED"
}
Webhook Events
The event property can be one of the following options:
| Event | Description |
|---|---|
READY | The flow is prepared (this can take time as some checks require preparation with 3rd parties). The professional can now be invited for the flow. |
UPDATED | The professional has progressed in the flow. It can be triggered by completing a check, by skipping a check, by modifying/re-completing a previous step, and more. This callback is intended to retrieve the check statuses to indicate progress to the recruiter. The data is not yet ready to be retrieved as the professional can still make modifications. |
FINISHED | The professional has confirmed submission of the flow. The flow data is now final and ready to be used. |
EXPIRED | The professional has not completed the flow in time. Due to GDPR constraints the submitted data is removed and the flow can no longer be completed. |
Custom Question Answers
The FINISHED payload carries two extra fields: customAnswers, the candidate's answers to the screening's custom questions, and customAnswersSubmittedAt, the moment those answers were submitted. Neither field is sent on the other events. A screening with no custom questions returns an empty array and null.
Custom questions are configured on a template and reach a screening through templateId — see Creating a Flow from a Template.
Structure
customAnswers contains one object per top-level question. Answers to sub-questions are not siblings in this array — they are nested under their parent in subAnswers. A sub-question can never itself have a sub-question, so the nesting is always exactly one level deep.
Only sub-questions the candidate actually saw are included. A sub-question belonging to a different trigger value than the candidate answered, or one hidden since, is omitted from subAnswers entirely rather than sent with an empty value — so you can distinguish "not asked" from "not answered".
Answer Fields
| Field | Type | Description |
|---|---|---|
customQuestionId | integer | ID of the question this answers |
value | string | The answer, always a string — including "yes"/"no" for a YES_NO question and a JSON array as a string for MULTI_CHOICE. Never a boolean or number |
documentReference | string/null | File ID of an uploaded supporting document |
extractedName | string/null | Name read from that document |
extractedDocumentType | string/null | Document type read from that document |
extractedIssuingParty | string/null | Issuing party read from that document |
extractedExpiryDate | string/null | Expiry date read from that document |
extractionStatus | string/null | SUCCESS / FAILED / NOT_APPLICABLE |
verdict | string/null | Whether the document supports the answer: SUPPORTED / NOT_FOUND / CONTRADICTED. null when the question has no answer verification. Advisory — it never blocks the screening |
verdictExplanation | string/null | Reasoning behind verdict |
identityMismatch | boolean/null | Whether the name on the document differs from the candidate's |
identityMismatchExplanation | string/null | Reasoning behind identityMismatch |
label | string | The question text as the candidate saw it |
questionType | string | OPEN_TEXT / YES_NO / SINGLE_CHOICE / MULTI_CHOICE / DATE / CONSENT |
validUntil | string/null | For a DATE answer on a question with a validity period: the date the answer expires. null otherwise |
validityStatus | string/null | VALID / EXPIRED, judged at the moment of submission. null when validUntil is null |
subAnswers | array | Answers to this question's sub-questions. Omitted entirely — not null, not [] — when the question has no sub-questions |
CONSENT is a declaration the candidate must tick to accept rather than a question with a right or wrong answer. Without acceptance the check is not complete.
validityStatus is a snapshotvalidityStatus is judged against customAnswersSubmittedAt, not against the moment the webhook fires or the moment you read it. It is not recalculated. If you need current validity, compare validUntil against today's date yourself.
customQuestionIdThese IDs are generated per environment: the same question has a different ID on test than on production, and a recreated question gets a new ID. Use the ID only to link a sub-answer to its parent within one payload, or to match against the questions from the status endpoint. Store label + questionType as your stable key.
Example
A FINISHED callback for a question answered "yes" and supported by an uploaded certificate, with one sub-question — a DATE question with a 12-month validity period, answered with a date that has since expired:
POST https://your.callback/url
Content-Type: application/json
{
"checkToken": "1234ab",
"event": "FINISHED",
"customAnswersSubmittedAt": "2026-09-18T09:15:00Z",
"customAnswers": [
{
"customQuestionId": 501,
"value": "yes",
"documentReference": "a1b2c3d4-0000-4aaa-8bbb-000000000001",
"extractedName": "J. de Vries",
"extractedDocumentType": "Certificate",
"extractedIssuingParty": "Healthcare Training Institute",
"extractedExpiryDate": "2027-03-01",
"extractionStatus": "SUCCESS",
"verdict": "SUPPORTED",
"verdictExplanation": "The certificate confirms the stated competency.",
"identityMismatch": false,
"identityMismatchExplanation": null,
"label": "Are you independently competent in administering medication?",
"questionType": "YES_NO",
"validUntil": null,
"validityStatus": null,
"subAnswers": [
{
"customQuestionId": 502,
"value": "2023-01-10",
"documentReference": null,
"extractedName": null,
"extractedDocumentType": null,
"extractedIssuingParty": null,
"extractedExpiryDate": null,
"extractionStatus": null,
"verdict": null,
"verdictExplanation": null,
"identityMismatch": null,
"identityMismatchExplanation": null,
"label": "When did you obtain this?",
"questionType": "DATE",
"validUntil": "2024-01-10",
"validityStatus": "EXPIRED"
}
]
}
]
}
The sub-answer has no subAnswers key at all, because a sub-question cannot have sub-questions of its own. A top-level question without sub-questions serialises exactly the same way.
Implementing Webhook Handler
Node.js Express Example
const express = require('express');
const app = express();
// Webhook endpoint
app.post('/webhooks/otentica', express.json(), async (req, res) => {
try {
const { event, checkToken } = req.body;
// Handle different events
switch (event) {
case 'READY':
await handleFlowReady(checkToken);
break;
case 'UPDATED':
await handleFlowUpdated(checkToken);
break;
case 'FINISHED':
await handleFlowFinished(checkToken);
break;
case 'EXPIRED':
await handleFlowExpired(checkToken);
break;
default:
console.log(`Unknown event: ${event}`);
}
// Always respond quickly
res.status(200).send('OK');
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).send('Internal server error');
}
});
// Event handlers
async function handleFlowReady(checkToken) {
// Send invite to professional
const flowData = await getFlowData(checkToken);
const screeningUrl = `https://app.otentica.nl/validation/${checkToken}`;
await sendInviteEmail(flowData.professionalEmail, screeningUrl);
}
async function handleFlowUpdated(checkToken) {
// Retrieve current flow status
const flowData = await getFlowData(checkToken);
// Update progress in your system
await updateFlowProgress(checkToken, flowData);
// Notify relevant parties about progress
await notifyProgressUpdate(checkToken);
}
async function handleFlowFinished(checkToken) {
// Retrieve final results
const results = await fetchFlowResults(checkToken);
// Process results
await processScreeningResults(results);
// Update candidate status
await updateCandidateStatus(checkToken, 'screening_complete');
}
Python Flask Example
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/otentica', methods=['POST'])
def handle_webhook():
try:
data = request.json
event = data.get('event')
check_token = data.get('checkToken')
# Process events
if event == 'READY':
handle_flow_ready(check_token)
elif event == 'UPDATED':
handle_flow_updated(check_token)
elif event == 'FINISHED':
handle_flow_finished(check_token)
elif event == 'EXPIRED':
handle_flow_expired(check_token)
return 'OK', 200
except Exception as e:
print(f'Error processing webhook: {e}')
return 'Internal error', 500
def handle_flow_ready(check_token):
# Send professional invite
screening_url = f'https://app.otentica.nl/validation/{check_token}'
send_invite_email(screening_url)
def handle_flow_updated(check_token):
# Retrieve and process current status
flow_data = get_flow_data(check_token)
update_progress(flow_data)
def handle_flow_finished(check_token):
# Fetch and process final results
results = fetch_flow_results(check_token)
process_results(results)
Security Best Practices
1. Use HTTPS
Always use HTTPS endpoints for webhooks:
https://api.yourcompany.com/webhooks/otentica ✓
http://api.yourcompany.com/webhooks/otentica ✗
3. Respond Quickly
- Acknowledge receipt immediately (200 OK)
- Process webhook asynchronously
- Timeout after 10 seconds triggers retry
4. Handle Retries
If your webhook endpoint fails to respond or returns an error:
- Implement idempotency to handle potential duplicate calls
- Store processed events to avoid duplicate processing
Testing Webhooks
Using ngrok for Local Development
# Install ngrok
npm install -g ngrok
# Expose local server
ngrok http 3000
# Use generated URL for webhook
# https://abc123.ngrok.io/webhooks/otentica
Webhook Testing Tool
// Test webhook locally
async function testWebhook() {
const testPayload = {
event: 'UPDATED',
checkToken: 'test-token-123'
};
const response = await fetch('http://localhost:3000/webhooks/otentica', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(testPayload)
});
console.log('Webhook test response:', response.status);
}
Troubleshooting
Common Issues
Webhooks not received:
- Verify callback URL is accessible
- Check firewall/security group settings
- Ensure HTTPS certificate is valid
Duplicate webhooks:
- Implement idempotency using event IDs
- Store processed events in database
Webhook timeouts:
- Respond immediately with 200 OK
- Process data asynchronously
- Increase server timeout settings