Developer guide
Statement-bundle webhooks
Configure an HTTPS callback URL on a bundle, or one for your whole account, and OptiTrust will post signed lifecycle events when the bundle or its files change.
Testing access
Discuss non-production testing before integration.
Contact Us to discuss non-production testing and access options.
Contact UsApplication limits
Plan for the current global limiter.
The application currently has an in-process global limiter of 600 requests per minute, partitioned by the remote IP observed by the application. Because it is global, it also applies to authenticated API calls; it is not a dedicated API quota. Requests rejected by the limiter receive HTTP 429.
Proxying and scale-out can affect the effective boundary, so this is not a throughput guarantee. The separate 30-per-minute public policy applies to selected anonymous endpoints and is not the authenticated API limit.
Delivery contract
At-least-once delivery. Deduplicate by event ID.
Every event is recorded in a durable outbox in the same database transaction as the change that caused it, so a committed change always has its event. A background dispatcher then delivers it. A delivery succeeds when your receiver answers with any 2xx status within 15 seconds; anything else (a non-2xx status, a timeout, a connection error) fails that attempt.
- At least once. The same event can arrive more than once, for example when your 2xx response is lost. Use
X-OptiTrust-Event-Id(alsoeventIdin the body) as the idempotency key. - No ordering guarantee. Events can arrive out of order, especially around retries.
occurredAtUtcorders the changes that triggered events; it is not a version of the payload. Fetch the bundle from the API when you need its current state. - Current state, historical trigger. The
bundleandfileobjects describe the state when the event is sent, not when it happened;event,changeKindandoccurredAtUtcdescribe what happened and when. - Unsafe or unsigned destinations fail at once. A URL that is not public HTTPS, or a destination without a signing secret, fails the event without retrying. Events with no destination configured are not sent.
| After attempt | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 3 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
| 9 | 48 hours |
| 10 | No further attempts: the event is marked failed. |
Times are approximate: the dispatcher runs every minute. After the tenth failed attempt the event is not retried; fetch the bundle from the API to reconcile.
Bundle responses expose up to the 10 most recent webhook delivery entries, with
dateUtc, status (delivered or failed),
and detail. The detail names the event, its ID, the attempt number, the failure
code (for example http_503 or timeout) and which secret signed it
(bundle or account-v2), never a secret value.
Configuration
Set webhookUrl when creating or updating a bundle.
Send an HTTPS URL in the bundle API request. A blank value disables the bundle's own
callbacks. On update, omit webhookUrl (or send null) to keep the
existing URL and secret; changing the URL keeps the same secret.
The bundle's webhookSecret is returned only by the response that generated it:
the 201 Created create response (and its 200 replay with the same
Idempotency-Key), or the update response that first set a webhookUrl.
Every other response, including GET, the bundle list and file completion, returns
webhookSecret: null. Store the secret when you receive it.
{
"idnumber": "8001015009087",
"type": "TransactionExtraction",
"dateRange": "Last90Days",
"proofOfAccount": "NotNeeded",
"webhookUrl": "https://client.example.com/optitrust/webhooks"
}
Account webhook
One endpoint for every bundle in your account.
Tenant administrators can set an account-level bundle webhook in the OptiTrust workspace,
on the company details page under Bundle webhooks. It receives events for
every bundle in the account, including bundles created in the workspace or by email. A
webhookUrl set on an individual bundle overrides it, and that bundle's events are
signed with the bundle's own webhookSecret.
The account signing secret is shown once, on the page that generated it, and never again. Rotate it in two steps so no delivery fails verification:
- Generate a new secret. It is pending: deliveries are still signed with the active secret. Configure your receiver to accept both.
- Activate the pending secret. Deliveries from then on are signed with it. Remove the old secret from your receiver once in-flight retries have drained.
A pending secret can be replaced before activation; the one shown earlier then cannot be
activated. The URL can only be set once a secret is active. Each payload carries
account.id, a stable public identifier for your account, so one receiver can
serve several OptiTrust accounts.
File upload
Upload files through a server-issued blob target.
Clients do not need shared Azure Blob Storage access. The bundle file endpoint is a
completion call, not a multipart upload. Request an upload target first, upload the PDF
bytes to the returned signed URL, then post the file metadata and uploadToken
back to OptiTrust.
1. Request a one-file upload target.
POST /api/bundles/{identifier}/files/upload-target
XApiKey: <api key>
{
"fileId": "84a60d05-7546-4731-a23a-e41777d891e4",
"uploadUri": "https://storage.example/optitrust-upload/84a60d05-7546-4731-a23a-e41777d891e4?...",
"uploadToken": "<signed token>"
}
2. Upload the PDF bytes directly to the signed URL.
PUT {uploadUri}
x-ms-blob-type: BlockBlob
Content-Type: application/pdf
<PDF bytes>
3. Complete the file attachment in OptiTrust.
POST /api/bundles/{identifier}/files
XApiKey: <api key>
Content-Type: application/json
{
"fileName": "bank-statement.pdf",
"fileId": "84a60d05-7546-4731-a23a-e41777d891e4",
"size": 1024,
"mimeType": "application/pdf",
"uploadToken": "<uploadToken from upload-target>"
}
POST /api/bundles/{identifier}/filesdoes not accept raw PDF bytes.- The
uploadUriis HTTPS-only, create-only, scoped to one server-chosen blob name, and expires after 20 minutes. - The
uploadTokenproves thefileIdwas issued by OptiTrust and expires after 30 minutes. - Only
application/pdffiles up to 10 MB are accepted. - A successful completion returns the updated bundle (with
webhookSecret: null) and sendsbundle.file_addedwhen callbacks are configured.
| Event | When it is sent |
|---|---|
bundle.created |
A bundle is created: through the API, in the workspace, or by email. |
bundle.updated |
The bundle is updated through the API (PUT) or its details are saved in the workspace, or its applicant upload link is issued, replaced or revoked. |
bundle.deleted |
The bundle is soft-deleted. |
bundle.status_changed |
The bundle status actually changes during review or processing. |
bundle.file_added |
A file is attached to the bundle (API, workspace, applicant upload link or email). |
bundle.file_deleted |
A bundle file is deleted. |
bundle.file_updated |
A file's API-visible result or review state changes. changeKind says how (below). |
bundle.erased |
The bundle was erased under the retention policy or on request (POPIA). Sent after the erasure, with identifiers only. Delete your copy of the bundle's personal data. |
bundle.recheck_completed |
A background recheck (POST /api/bundles/{identifier}/files/retry, or a
PUT that changed the ID number or a name) finished, even when no document
changed. Its recheck object carries the job's jobId,
kind, status, counts and completedAtUtc
as they were when it finished.
|
bundle.transactions_ready |
A document's transactions were stored, a recheck changed what the transaction endpoints
return for that document (a change to data the API does not return does not trigger it), or
approving a statement with issues made them available (one event per document). Its
transactions object
carries the accountId and the stored transactionCount only, never the
transactions or the account number: fetch them with
GET /api/bundles/{identifier}/files/{fileId}/transactions or the account
endpoints. accountId is null while account downloads are not
configured for the platform; then use the event's file.id with the per-file
endpoint. Like every event it can arrive out of order (for example before
bundle.file_added), and it is skipped when the document or bundle has since been
deleted or no longer has transactions.
|
changeKind |
Meaning |
|---|---|
result_changed | A re-check changed the file's result (code or issues). |
approved | OptiTrust manually approved the file's issues. |
unapproved | A manual approval was withdrawn. |
flagged | The file was flagged for review. |
unflagged | The review flag was cleared. |
restored | A deleted file was restored. |
changeKind is null for every other event. Treat unknown events and
unknown changeKind values as a signal to fetch the bundle; new ones may be added.
Signature
Verify every delivery before processing it.
X-OptiTrust-Event-Idcontains the idempotency key for this delivery.X-OptiTrust-Eventcontains the event name.X-OptiTrust-Timestampcontains the Unix timestamp in seconds.X-OptiTrust-Signaturecontainssha256=<hex>.
Compute HMAC-SHA256 over this UTF-8 string, using the bundle webhookSecret when
the bundle has its own webhookUrl, or else your account signing secret, then
compare it with the signature header using a constant-time comparison:
{timestamp}.{eventId}.{rawJsonBody}
X-OptiTrust-Timestamp is the time of this attempt, so a retried delivery carries a
fresh timestamp. Reject unsigned requests and stale timestamps, and treat a repeated event ID
as already processed. Return any 2xx response only after the payload has been accepted.
Payload
Use eventId for idempotency and inspect the bundle or file object.
Use file.result as the machine-readable screening outcome. The
bundle status describes workflow state only and should not be used
as the anti-fraud verdict.
occurredAtUtc is when the change happened. For the few events queued by the
previous delivery system before this contract, it is the send time instead.
file is null for bundle-level events, and
{ "id": 42, "missing": true } if the file no longer exists.
{
"eventId": "8f55f15d0c1f4b4c96f403edb89bb720",
"event": "bundle.file_updated",
"changeKind": "flagged",
"occurredAtUtc": "2026-07-10T08:30:00Z",
"account": {
"id": "5b0f0c52-7a55-4c3e-9f0e-2f4d6d1f7a11"
},
"bundle": {
"identifier": "c73c4e02-85c5-4b1b-9ad0-9af82b46ec22",
"clientReference": "loan-10042",
"status": "Awaiting documents",
"dateAddedUtc": "2026-07-10T08:00:00Z",
"deleted": false,
"fileCount": 2
},
"file": {
"id": 42,
"fileName": "bank-statement.pdf",
"status": "Pending",
"result": {
"processingState": "pending",
"code": "ToBeProcessed",
"label": "To be processed",
"verdict": "pending",
"riskBand": "not_applicable",
"riskScore": null,
"issues": [],
"manuallyApproved": false,
"flaggedForReview": true
},
"deleted": false,
"deletedAtUtc": null
}
}
bundle.erased carries identifiers only:
{
"eventId": "0c1d9a6e4f2b4d8e9a7b3c5d1e2f3a4b",
"event": "bundle.erased",
"occurredAtUtc": "2026-12-01T04:30:12Z",
"account": {
"id": "5b0f0c52-7a55-4c3e-9f0e-2f4d6d1f7a11"
},
"bundle": {
"identifier": "c73c4e02-85c5-4b1b-9ad0-9af82b46ec22"
}
}
bundle.transactions_ready adds a transactions object (the bundle and
file objects are as above):
{
"eventId": "3e9b7c1d2a4f4e6b8c0d1f2a3b4c5d6e",
"event": "bundle.transactions_ready",
"changeKind": null,
"occurredAtUtc": "2026-07-10T08:31:05Z",
"account": { "id": "5b0f0c52-7a55-4c3e-9f0e-2f4d6d1f7a11" },
"bundle": { "identifier": "c73c4e02-85c5-4b1b-9ad0-9af82b46ec22", ... },
"file": { "id": 42, ... },
"transactions": {
"accountId": "kq3Vb0mXo9yR2cJ7tWzL4hN8pF1sD6aE5uG0iT3xY2w",
"transactionCount": 184
}
}
Receiver guidance
- Store the
webhookSecret(and your account signing secret) securely and treat them as credentials. They are shown once. -
Deduplicate by
X-OptiTrust-Event-Id, and record the event ID in the same database transaction as its side effects, so both commit or neither does. Alternatively, write the raw delivery to a durable inbox, return 2xx only after that commit, and process the inbox afterwards. Never mark an event as processed before its work has committed: unfinished work must stay recoverable. -
Do not rely on arrival order.
occurredAtUtcorders the triggering changes only; it is not a version of thebundleorfilestate in the payload. Reconcile current state by fetching the bundle from the API. -
On
bundle.erased, delete the personal data you hold for that bundle and keep an erasure tombstone for itsidentifier, so a delayed or retried ordinary event for that bundle cannot recreate the data you deleted. - Use
file.resultfor the screening outcome;bundle.statusis the bundle workflow state. - Fetch the latest bundle from the API if your system needs current state after receiving an event.
- Review
webhookDeliverieson the bundle response when diagnosing delivery failures.