Skip to main content

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:

EventDescription
READYThe flow is prepared (this can take time as some checks require preparation with 3rd parties). The professional can now be invited for the flow.
UPDATEDThe 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.
FINISHEDThe professional has confirmed submission of the flow. The flow data is now final and ready to be used.
EXPIREDThe 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​

FieldTypeDescription
customQuestionIdintegerID of the question this answers
valuestringThe 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
documentReferencestring/nullFile ID of an uploaded supporting document
extractedNamestring/nullName read from that document
extractedDocumentTypestring/nullDocument type read from that document
extractedIssuingPartystring/nullIssuing party read from that document
extractedExpiryDatestring/nullExpiry date read from that document
extractionStatusstring/nullSUCCESS / FAILED / NOT_APPLICABLE
verdictstring/nullWhether the document supports the answer: SUPPORTED / NOT_FOUND / CONTRADICTED. null when the question has no answer verification. Advisory — it never blocks the screening
verdictExplanationstring/nullReasoning behind verdict
identityMismatchboolean/nullWhether the name on the document differs from the candidate's
identityMismatchExplanationstring/nullReasoning behind identityMismatch
labelstringThe question text as the candidate saw it
questionTypestringOPEN_TEXT / YES_NO / SINGLE_CHOICE / MULTI_CHOICE / DATE / CONSENT
validUntilstring/nullFor a DATE answer on a question with a validity period: the date the answer expires. null otherwise
validityStatusstring/nullVALID / EXPIRED, judged at the moment of submission. null when validUntil is null
subAnswersarrayAnswers 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 snapshot

validityStatus 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.

Do not match on customQuestionId

These 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